diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index a2fe12438c..dd01dea827 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -294,6 +294,7 @@ export default defineConfig([ "src/features/dataConnectorsV2/api/data-connectors.api.ts", "src/features/dataConnectorsV2/api/doiResolver.generated-api.ts", "src/features/notifications/api/notifications.generated-api.ts", + "src/features/persistedLogs/api/persistedLogs.generated-api.ts", "src/features/platform/api/platform.generated-api.ts", "src/features/projectsV2/api/projectV2.api.ts", "src/features/searchV2/api/searchV2Api.generated-api.ts", diff --git a/client/package-lock.json b/client/package-lock.json index 44161f028b..c33f563010 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -58,7 +58,8 @@ "remark-gemoji": "^8.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "sass": "^1.64.1" + "sass": "^1.64.1", + "ulid": "^3.0.2" }, "devDependencies": { "@eslint/compat": "^2.0.2", @@ -19665,6 +19666,15 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/ulid": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz", + "integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==", + "license": "MIT", + "bin": { + "ulid": "dist/cli.js" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/client/package.json b/client/package.json index 3806b4b29c..2bb8af5ee6 100644 --- a/client/package.json +++ b/client/package.json @@ -36,6 +36,7 @@ "generate-api:sessionLaunchersV2": "rtk-query-codegen-openapi src/features/sessionsV2/api/sessionLaunchersV2.api-config.ts", "generate-api:sessionsV2": "rtk-query-codegen-openapi src/features/sessionsV2/api/sessionsV2.api-config.ts", "generate-api:users": "rtk-query-codegen-openapi src/features/usersV2/api/users.api-config.ts", + "generate-api:persistedLogs": "rtk-query-codegen-openapi src/features/persistedLogs/api/persistedLogs.api-config.ts", "update-api": "node scripts/update_api_spec.js computeResources connectedServices dataConnectors namespaceV2 notifications platform projectCloudStorage projectV2 repositories searchV2 sessionLaunchersV2 sessionsV2 users", "update-api:computeResources": "node scripts/update_api_spec.js computeResources", "update-api:connectedServices": "node scripts/update_api_spec.js connectedServices", @@ -49,7 +50,8 @@ "update-api:searchV2": "node scripts/update_api_spec.js searchV2", "update-api:sessionLaunchersV2": "node scripts/update_api_spec.js sessionLaunchersV2", "update-api:sessionsV2": "node scripts/update_api_spec.js sessionsV2", - "update-api:users": "node scripts/update_api_spec.js users" + "update-api:users": "node scripts/update_api_spec.js users", + "update-api:persistedLogs": "node scripts/update_api_spec.js persistedLogs" }, "type": "module", "dependencies": { @@ -103,7 +105,8 @@ "remark-gemoji": "^8.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "sass": "^1.64.1" + "sass": "^1.64.1", + "ulid": "^3.0.2" }, "devDependencies": { "@eslint/compat": "^2.0.2", diff --git a/client/scripts/update_api_spec.js b/client/scripts/update_api_spec.js index aef8c17867..29126cdc5b 100644 --- a/client/scripts/update_api_spec.js +++ b/client/scripts/update_api_spec.js @@ -24,7 +24,7 @@ import { parseDocument } from "yaml"; const GH_BASE_URL = "https://raw.githubusercontent.com"; const DATA_SERVICES_REPO = "SwissDataScienceCenter/renku-data-services"; -const DATA_SERVICES_RELEASE = "main"; +const DATA_SERVICES_RELEASE = "leafty/feat-peristed-logs"; async function main() { argv.forEach((arg) => { @@ -54,6 +54,8 @@ async function main() { updateSessionsV2Api(); } else if (arg.trim() === "users") { updateUsersApi(); + } else if (arg.trim() === "persistedLogs") { + updatePersistedLogsApi(); } }); } @@ -150,6 +152,13 @@ async function updateUsersApi() { }); } +async function updatePersistedLogsApi() { + updateApiFiles({ + specFile: "components/renku_data_services/persisted_logs/api.spec.yaml", + destFile: "src/features/persistedLogs/api/persistedLogs.openapi.json", + }); +} + async function updateApiFiles({ specFile, destFile }) { const API_SPEC_FILE = specFile; const DEST_FILE = destFile; diff --git a/client/src/features/logsDisplay/BuildLogsModal.tsx b/client/src/features/logsDisplay/BuildLogsModal.tsx index 5b9ef43484..f67d1c6c9d 100644 --- a/client/src/features/logsDisplay/BuildLogsModal.tsx +++ b/client/src/features/logsDisplay/BuildLogsModal.tsx @@ -20,15 +20,15 @@ import { skipToken } from "@reduxjs/toolkit/query"; import { useCallback, useMemo } from "react"; import { - sessionLaunchersV2Api, - useGetBuildsByBuildIdLogsQuery as useGetBuildLogsQuery, + persistedLogsApi, + useGetPersistedBuildLogsForModalQuery, +} from "../persistedLogs/api/persistedLogs.api"; +import { type Build, type BuildList, } from "../sessionsV2/api/sessionLaunchersV2.api"; import LogsModal from "./LogsModal"; -const BUILD_LOGS_MAX_LINES = 250; - interface BuildLogsModalProps { builds: BuildList | undefined; isOpen: boolean; @@ -74,17 +74,16 @@ function BuildLogsModalInner({ isOpen, toggle, }: BuildLogsModalInnerProps) { - const query = useGetBuildLogsQuery( + const query = useGetPersistedBuildLogsForModalQuery( isOpen ? { buildId: build.id, - maxLines: BUILD_LOGS_MAX_LINES, } : skipToken, ); const [trigger] = - sessionLaunchersV2Api.endpoints.getBuildsByBuildIdLogs.useLazyQuery(); + persistedLogsApi.endpoints.getPersistedBuildLogsForModal.useLazyQuery(); const downloadQueryTrigger = useCallback( () => trigger({ buildId: build.id }), [build.id, trigger], diff --git a/client/src/features/logsDisplay/LogsModal.tsx b/client/src/features/logsDisplay/LogsModal.tsx index 7877451267..146fc14e3d 100644 --- a/client/src/features/logsDisplay/LogsModal.tsx +++ b/client/src/features/logsDisplay/LogsModal.tsx @@ -140,7 +140,7 @@ export default function LogsModal({ type LogsModalBodyProps = LogsQuery & Pick; -function LogsModalBody({ +export function LogsModalBody({ data, error, isFetching, @@ -355,7 +355,7 @@ function ModalFooterButtons({ * * NOTE: will download with maxLines = 250, so the logs will be incomplete */ -function useDownloadLogs( +export function useDownloadLogs( name: string, refetch: LogsQuery["refetch"], downloadQueryTrigger: DownloadLogsLazyQueryTrigger | undefined | null, diff --git a/client/src/features/logsDisplay/SessionLogsModal.tsx b/client/src/features/logsDisplay/SessionLogsModal.tsx index 1fb2da7724..feea4136c0 100644 --- a/client/src/features/logsDisplay/SessionLogsModal.tsx +++ b/client/src/features/logsDisplay/SessionLogsModal.tsx @@ -20,6 +20,11 @@ import { skipToken } from "@reduxjs/toolkit/query"; import { useCallback } from "react"; import { + persistedLogsApi, + useGetPersistedLogsForModalQuery, +} from "../persistedLogs/api/persistedLogs.api"; +import { + SessionResponse, sessionsV2Api, useGetSessionsBySessionIdLogsQuery, useGetSessionsBySessionIdQuery, @@ -43,6 +48,37 @@ export default function SessionLogsModal({ sessionName ? { sessionId: sessionName } : skipToken, ); + if (session?.session_type === "non-interactive") { + return ( + + ); + } + + return ( + + ); +} + +interface KubernetesLogsModalProps extends SessionLogsModalProps { + session: SessionResponse | undefined; +} + +function KubernetesLogsModal({ + isOpen, + session, + sessionName, + toggle, +}: KubernetesLogsModalProps) { const query = useGetSessionsBySessionIdLogsQuery( isOpen ? { @@ -78,3 +114,55 @@ export default function SessionLogsModal({ /> ); } + +interface PersistedLogsModalProps extends SessionLogsModalProps { + session: SessionResponse; +} + +function PersistedLogsModal({ + isOpen, + session, + sessionName, + toggle, +}: PersistedLogsModalProps) { + const query = useGetPersistedLogsForModalQuery( + isOpen + ? { + launcherId: session.launcher_id, + params: { + submission_id: session.submission_id, + }, + } + : skipToken, + ); + + const [trigger] = + persistedLogsApi.endpoints.getPersistedLogsForModal.useLazyQuery(); + const downloadQueryTrigger = useCallback( + () => + trigger({ + launcherId: session.launcher_id, + params: { submission_id: session.submission_id }, + }), + [session.launcher_id, session.submission_id, trigger], + ); + + return ( + + ); +} diff --git a/client/src/features/persistedLogs/SessionsPersistedLogsHistoryModal.tsx b/client/src/features/persistedLogs/SessionsPersistedLogsHistoryModal.tsx new file mode 100644 index 0000000000..ef8829ca3c --- /dev/null +++ b/client/src/features/persistedLogs/SessionsPersistedLogsHistoryModal.tsx @@ -0,0 +1,408 @@ +/*! + * Copyright 2026 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { SerializedError } from "@reduxjs/toolkit"; +import { skipToken, type FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import cx from "classnames"; +import { DateTime, Duration } from "luxon"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowLeft, + ArrowRepeat, + ClockHistory, + FileEarmarkArrowDown, + XLg, +} from "react-bootstrap-icons"; +import { + Button, + ListGroup, + ListGroupItem, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; +import { decodeTime as decodeTimeULID, isValid as isValidULID } from "ulid"; + +import RtkOrDataServicesError from "~/components/errors/RtkOrDataServicesError"; +import { Loader } from "~/components/Loader"; +import ScrollableModal from "~/components/modal/ScrollableModal"; +import { toHumanDateTime } from "~/utils/helpers/DateTimeUtils"; +import { toHumanRelativeDuration } from "~/utils/helpers/DurationUtils"; +import { LogsModalBody, useDownloadLogs } from "../logsDisplay/LogsModal"; +import type { SessionLauncher } from "../sessionsV2/api/sessionLaunchersV2.api"; +import { + persistedLogsApi, + useGetPersistedLogsForModalQuery, + useGetPersistedLogsSessionsByLauncherIdRunsQuery, + type SessionRun, + type SessionRuns, +} from "./api/persistedLogs.api"; + +interface PersistedLogsHistoryModalProps { + isOpen: boolean; + launcher: SessionLauncher; + toggle: () => void; +} + +export default function SessionsPersistedLogsHistoryModal({ + isOpen, + launcher, + toggle, +}: PersistedLogsHistoryModalProps) { + const { + data: sessionRuns, + isFetching, + isLoading, + error, + refetch, + } = useGetPersistedLogsSessionsByLauncherIdRunsQuery( + isOpen + ? { + launcherId: launcher.id, + } + : skipToken, + ); + + const [selectedRunId, setSelectedRunId] = useState(""); + const onClickBack = useCallback(() => { + setSelectedRunId(""); + }, []); + + return ( + + + + Logs history for {launcher.name} + + + + + + {isOpen && selectedRunId ? ( + <> + + + + ) : ( + + )} + + + + ); +} + +interface LogsHistoryBodyProps { + error: FetchBaseQueryError | SerializedError | undefined; + isLoading: boolean; + isOpen: boolean; + selectedRunId: string; + sessionRuns: SessionRuns | undefined; + setSelectedRunId: (runId: string) => void; +} + +function LogsHistoryBody({ + error, + isLoading, + isOpen, + selectedRunId, + sessionRuns, + setSelectedRunId, +}: LogsHistoryBodyProps) { + const selectedSessionRun = useMemo( + () => + selectedRunId + ? sessionRuns?.find(({ id }) => id === selectedRunId) + : null, + [selectedRunId, sessionRuns], + ); + + useEffect(() => { + if (selectedRunId && selectedSessionRun == null) { + setSelectedRunId(""); + } + }, [selectedRunId, selectedSessionRun, setSelectedRunId]); + + if (isLoading) { + return ; + } + + if (error || sessionRuns == null) { + return ( + <> +

Logs history not available.

+ + + ); + } + + if (sessionRuns.length == 0) { + return ( +

+ The logs history is empty. Note that logs are purged after a while. +

+ ); + } + + if (selectedSessionRun) { + return ; + } + + return ( + + {sessionRuns.map((sessionRun) => ( + + ))} + + ); +} + +interface SessionRunItemProps { + sessionRun: SessionRun; + setSelectedRunId: (runId: string) => void; +} + +function SessionRunItem({ sessionRun, setSelectedRunId }: SessionRunItemProps) { + const timestamp = useMemo( + () => + isValidULID(sessionRun.id) + ? DateTime.fromMillis(decodeTimeULID(sessionRun.id)) // eslint-disable-line spellcheck/spell-checker + : null, + [sessionRun.id], + ); + + const onClick = useCallback(() => { + setSelectedRunId(sessionRun.id); + }, [sessionRun.id, setSelectedRunId]); + + const [now, setNow] = useState(DateTime.utc()); + const timeoutRef = useRef(null); + + // Refresh every duration / 10, clamped to [5 seconds, 10 minutes] + useEffect(() => { + if (timestamp == null || !timestamp.isValid) { + return; + } + + const duration = now.diff(timestamp); + /* eslint-disable spellcheck/spell-checker */ + const refresh = Math.min( + Math.max( + duration.toMillis() / 10, + Duration.fromObject({ seconds: 5 }).toMillis(), + ), + Duration.fromObject({ minutes: 10 }).toMillis(), + ); + /* eslint-enable spellcheck/spell-checker */ + + timeoutRef.current = window.setTimeout(() => { + setNow(DateTime.utc()); + }, refresh); + return () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = null; + }; + }, [now, timestamp]); + + return ( + + {sessionRun.submission_id && ( + <> + {sessionRun.submission_id} + {" | "} + + )} + <> + {timestamp ? ( + <> + {toHumanDateTime({ datetime: timestamp })} ( + {toHumanRelativeDuration({ datetime: timestamp, now })}) + + ) : ( + sessionRun.id + )} + + + ); +} + +interface LogsDisplayProps { + isOpen: boolean; + sessionRun: SessionRun; +} + +function LogsDisplay({ isOpen, sessionRun }: LogsDisplayProps) { + const query = useGetPersistedLogsForModalQuery( + isOpen + ? { + launcherId: sessionRun.launcher_id, + params: { + run_id: sessionRun.id, + submission_id: sessionRun.submission_id, + }, + } + : skipToken, + ); + + return ( + + ); +} + +interface LogsActionsProps { + selectedRunId: string; + sessionRuns: SessionRuns | undefined; +} + +function LogsActions({ selectedRunId, sessionRuns }: LogsActionsProps) { + const sessionRun = useMemo( + () => + selectedRunId + ? sessionRuns?.find(({ id }) => id === selectedRunId) + : null, + [selectedRunId, sessionRuns], + ); + + if (sessionRun == null) { + return null; + } + + return ; +} + +interface LogsActionsInnerProps { + sessionRun: SessionRun; +} + +function LogsActionsInner({ sessionRun }: LogsActionsInnerProps) { + const logsName = `${sessionRun.submission_id ? sessionRun.submission_id + "_" : ""}${sessionRun.launcher_id}`; + + const query = useGetPersistedLogsForModalQuery({ + launcherId: sessionRun.launcher_id, + params: { + run_id: sessionRun.id, + submission_id: sessionRun.submission_id, + }, + }); + + const [trigger] = + persistedLogsApi.endpoints.getPersistedLogsForModal.useLazyQuery(); + const downloadQueryTrigger = useCallback( + () => + trigger({ + launcherId: sessionRun.launcher_id, + params: { submission_id: sessionRun.submission_id }, + }), + [sessionRun.launcher_id, sessionRun.submission_id, trigger], + ); + + const [isDownloading, triggerDownload] = useDownloadLogs( + logsName, + query.refetch, + downloadQueryTrigger, + ); + const canDownload = + !query.isFetching && + !isDownloading && + query.data != null && + Object.keys(query.data).length >= 1; + + return ( + <> + + + + + ); +} diff --git a/client/src/features/persistedLogs/api/persistedLogs.api-config.ts b/client/src/features/persistedLogs/api/persistedLogs.api-config.ts new file mode 100644 index 0000000000..e9e75c854d --- /dev/null +++ b/client/src/features/persistedLogs/api/persistedLogs.api-config.ts @@ -0,0 +1,33 @@ +/*! + * Copyright 2026 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run `npm run generate-api:persistedLogs` to generate the API + +import path from "path"; +import type { ConfigFile } from "@rtk-query/codegen-openapi"; + +const config: ConfigFile = { + apiFile: "./persistedLogs.empty-api.ts", + apiImport: "persistedLogsEmptyApi", + outputFile: "./persistedLogs.generated-api.ts", + exportName: "persistedLogsGeneratedApi", + hooks: false, + schemaFile: path.join(__dirname, "persistedLogs.openapi.json"), +}; + +export default config; diff --git a/client/src/features/persistedLogs/api/persistedLogs.api.ts b/client/src/features/persistedLogs/api/persistedLogs.api.ts new file mode 100644 index 0000000000..71ca395996 --- /dev/null +++ b/client/src/features/persistedLogs/api/persistedLogs.api.ts @@ -0,0 +1,119 @@ +/*! + * Copyright 2025 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + GetPersistedLogsBuildsByBuildIdApiArg, + GetPersistedLogsBuildsByBuildIdApiResponse, + persistedLogsGeneratedApi, + type GetPersistedLogsSessionsByLauncherIdApiArg, + type GetPersistedLogsSessionsByLauncherIdApiResponse, +} from "./persistedLogs.generated-api"; + +// Fixes some API endpoints +const withFixedEndpoints = persistedLogsGeneratedApi.injectEndpoints({ + endpoints: (build) => ({ + getPersistedLogsSessionsByLauncherId: build.query< + GetPersistedLogsSessionsByLauncherIdApiResponse, + GetPersistedLogsSessionsByLauncherIdApiArg + >({ + query: ({ launcherId, params }) => ({ + url: `/persisted_logs/sessions/${launcherId}`, + params, + }), + }), + }), + overrideExisting: true, +}); + +// Adds a data transform for compatibility with logs modal +const withTransformedEndpoints = withFixedEndpoints.injectEndpoints({ + endpoints: (build) => ({ + getPersistedLogsForModal: build.query< + Record, + GetPersistedLogsSessionsByLauncherIdApiArg + >({ + query: ({ launcherId, params }) => ({ + url: `/persisted_logs/sessions/${launcherId}`, + params, + }), + transformResponse: ( + result: GetPersistedLogsSessionsByLauncherIdApiResponse, + ) => { + const asRecord = result.logs.reduce( + (prev, { container, logs }) => ({ + ...prev, + [container]: logs.map(({ log_line }) => log_line).join(""), + }), + {} as Record, + ); + return asRecord; + }, + }), + getPersistedBuildLogsForModal: build.query< + Record, + GetPersistedLogsBuildsByBuildIdApiArg + >({ + query: ({ buildId }) => ({ + url: `/persisted_logs/builds/${buildId}`, + }), + transformResponse: ( + result: GetPersistedLogsBuildsByBuildIdApiResponse, + ) => { + const asRecord = result.logs.reduce( + (prev, { container, logs }) => ({ + ...prev, + [container]: logs.map(({ log_line }) => log_line).join(""), + }), + {} as Record, + ); + return asRecord; + }, + }), + }), +}); + +// Adds tag handling for cache management +export const persistedLogsApi = withTransformedEndpoints.enhanceEndpoints({ + addTagTypes: ["PersistedLogs", "SessionRun"], + endpoints: { + getPersistedLogsSessionsByLauncherId: { + providesTags: (result) => + result + ? [{ id: result.run.id, type: "PersistedLogs" }, "PersistedLogs"] + : ["PersistedLogs"], + }, + getPersistedLogsSessionsByLauncherIdRuns: { + providesTags: (result) => + result + ? [ + ...result.map(({ id }) => ({ id, type: "SessionRun" as const })), + "SessionRun", + ] + : ["SessionRun"], + }, + }, +}); + +export const { + useGetPersistedLogsSessionsByLauncherIdQuery, + useGetPersistedLogsSessionsByLauncherIdRunsQuery, + useGetPersistedLogsForModalQuery, + useGetPersistedLogsBuildsByBuildIdQuery, + useGetPersistedBuildLogsForModalQuery, +} = persistedLogsApi; +export type * from "./persistedLogs.generated-api"; diff --git a/client/src/features/persistedLogs/api/persistedLogs.empty-api.ts b/client/src/features/persistedLogs/api/persistedLogs.empty-api.ts new file mode 100644 index 0000000000..2b1da4902a --- /dev/null +++ b/client/src/features/persistedLogs/api/persistedLogs.empty-api.ts @@ -0,0 +1,26 @@ +/*! + * Copyright 2026 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; + +// initialize an empty api service that we'll inject endpoints into later as needed +export const persistedLogsEmptyApi = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: "/api/data" }), + endpoints: () => ({}), + reducerPath: "persistedLogsApi", +}); diff --git a/client/src/features/persistedLogs/api/persistedLogs.generated-api.ts b/client/src/features/persistedLogs/api/persistedLogs.generated-api.ts new file mode 100644 index 0000000000..335b7e0033 --- /dev/null +++ b/client/src/features/persistedLogs/api/persistedLogs.generated-api.ts @@ -0,0 +1,93 @@ +import { persistedLogsEmptyApi as api } from "./persistedLogs.empty-api"; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getPersistedLogsSessionsByLauncherId: build.query< + GetPersistedLogsSessionsByLauncherIdApiResponse, + GetPersistedLogsSessionsByLauncherIdApiArg + >({ + query: (queryArg) => ({ + url: `/persisted_logs/sessions/${queryArg.launcherId}`, + params: { + params: queryArg.params, + }, + }), + }), + getPersistedLogsSessionsByLauncherIdRuns: build.query< + GetPersistedLogsSessionsByLauncherIdRunsApiResponse, + GetPersistedLogsSessionsByLauncherIdRunsApiArg + >({ + query: (queryArg) => ({ + url: `/persisted_logs/sessions/${queryArg.launcherId}/runs`, + }), + }), + getPersistedLogsBuildsByBuildId: build.query< + GetPersistedLogsBuildsByBuildIdApiResponse, + GetPersistedLogsBuildsByBuildIdApiArg + >({ + query: (queryArg) => ({ + url: `/persisted_logs/builds/${queryArg.buildId}`, + }), + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as persistedLogsGeneratedApi }; +export type GetPersistedLogsSessionsByLauncherIdApiResponse = + /** status 200 The session logs from the corresponding run and container, paginated. + */ PersistedSessionLogs; +export type GetPersistedLogsSessionsByLauncherIdApiArg = { + launcherId: Ulid; + params?: PersistedLogsGetQuery; +}; +export type GetPersistedLogsSessionsByLauncherIdRunsApiResponse = + /** status 200 The session runs for which logs exist. + */ SessionRuns; +export type GetPersistedLogsSessionsByLauncherIdRunsApiArg = { + launcherId: Ulid; +}; +export type GetPersistedLogsBuildsByBuildIdApiResponse = + /** status 200 The image build logs from the corresponding image build. + */ PersistedBuildLogs; +export type GetPersistedLogsBuildsByBuildIdApiArg = { + buildId: Ulid; +}; +export type Ulid = string; +export type SessionRun = { + id: Ulid; + session_uid?: string; + launcher_id: Ulid; + submission_id?: string; +}; +export type NanoTimestamp = string; +export type PersistedLogLine = { + timestamp: NanoTimestamp; + log_line: string; +}; +export type PersistedLogLines = PersistedLogLine[]; +export type SessionRunLog = { + container: string; + logs: PersistedLogLines; +}; +export type SessionRunLogs = SessionRunLog[]; +export type PersistedSessionLogs = { + run: SessionRun; + logs: SessionRunLogs; +}; +export type ErrorResponse = { + error: { + code: number; + detail?: string; + message: string; + /** Sentry trace ID for linking to corresponding log entries */ + trace_id?: string; + }; +}; +export type PersistedLogsGetQuery = { + run_id?: Ulid; + submission_id?: string; +}; +export type SessionRuns = SessionRun[]; +export type PersistedBuildLogs = { + logs: SessionRunLogs; +}; diff --git a/client/src/features/persistedLogs/api/persistedLogs.openapi.json b/client/src/features/persistedLogs/api/persistedLogs.openapi.json new file mode 100644 index 0000000000..0b7e2d80e2 --- /dev/null +++ b/client/src/features/persistedLogs/api/persistedLogs.openapi.json @@ -0,0 +1,304 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Renku Data Services API", + "description": "This service is the main backend for Renku. It provides information about users, projects,\ncloud storage, access to compute resources and many other things.\n", + "version": "v1" + }, + "servers": [ + { + "url": "/api/data" + } + ], + "paths": { + "/persisted_logs/sessions/{launcher_id}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "launcher_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + }, + { + "in": "query", + "name": "params", + "style": "form", + "explode": true, + "schema": { + "$ref": "#/components/schemas/PersistedLogsGetQuery" + } + } + ], + "responses": { + "200": { + "description": "The session logs from the corresponding run and container, paginated.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersistedSessionLogs" + } + } + } + }, + "404": { + "description": "The session launcher does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["persisted_logs"] + } + }, + "/persisted_logs/sessions/{launcher_id}/runs": { + "get": { + "parameters": [ + { + "in": "path", + "name": "launcher_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + } + ], + "responses": { + "200": { + "description": "The session runs for which logs exist.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionRuns" + } + } + } + }, + "404": { + "description": "The session launcher does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["persisted_logs"] + } + }, + "/persisted_logs/builds/{build_id}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "build_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + } + ], + "responses": { + "200": { + "description": "The image build logs from the corresponding image build.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersistedBuildLogs" + } + } + } + }, + "404": { + "description": "The image build does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["persisted_logs"] + } + } + }, + "components": { + "schemas": { + "PersistedSessionLogs": { + "description": "Logs for a session", + "type": "object", + "properties": { + "run": { + "$ref": "#/components/schemas/SessionRun" + }, + "logs": { + "$ref": "#/components/schemas/SessionRunLogs" + } + }, + "required": ["run", "logs"] + }, + "SessionRuns": { + "description": "A list of session runs", + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionRun" + } + }, + "SessionRun": { + "description": "A session run", + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/Ulid" + }, + "session_uid": { + "type": "string" + }, + "launcher_id": { + "$ref": "#/components/schemas/Ulid" + }, + "submission_id": { + "type": "string" + } + }, + "required": ["id", "launcher_id"] + }, + "PersistedBuildLogs": { + "description": "Logs for an image build", + "type": "object", + "properties": { + "logs": { + "$ref": "#/components/schemas/SessionRunLogs" + } + }, + "required": ["logs"] + }, + "SessionRunLogs": { + "description": "Logs of a session run, organized by pod container", + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionRunLog" + } + }, + "SessionRunLog": { + "description": "Logs of a single container", + "type": "object", + "properties": { + "container": { + "type": "string" + }, + "logs": { + "$ref": "#/components/schemas/PersistedLogLines" + } + }, + "required": ["container", "logs"] + }, + "PersistedLogLines": { + "description": "Stream of log lines", + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistedLogLine" + } + }, + "PersistedLogLine": { + "description": "A timestamped log line", + "type": "object", + "properties": { + "timestamp": { + "$ref": "#/components/schemas/NanoTimestamp" + }, + "log_line": { + "type": "string" + } + }, + "required": ["timestamp", "log_line"] + }, + "Ulid": { + "description": "ULID identifier", + "type": "string", + "minLength": 26, + "maxLength": 26, + "pattern": "^[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "NanoTimestamp": { + "description": "UNIX timestamp in nanoseconds", + "type": "string", + "pattern": "[0-9]+" + }, + "PersistedLogsGetQuery": { + "description": "Query params for querying persisted logs", + "type": "object", + "properties": { + "run_id": { + "$ref": "#/components/schemas/Ulid" + }, + "submission_id": { + "type": "string" + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "example": 1404 + }, + "detail": { + "type": "string", + "example": "A more detailed optional message showing what the problem was" + }, + "message": { + "type": "string", + "example": "Something went wrong - please try again later" + }, + "trace_id": { + "type": "string", + "example": "ac93950e9e114a55c67fb8e5ef519bbe", + "description": "Sentry trace ID for linking to corresponding log entries" + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + } + }, + "responses": { + "Error": { + "description": "The schema for all 4xx and 5xx responses", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } +} diff --git a/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx index af6c005393..516a35de0b 100644 --- a/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx @@ -21,6 +21,7 @@ import cx from "classnames"; import { useMemo } from "react"; import { Braces, + ClockHistory, Link45deg, Pencil, PlayCircle, @@ -69,6 +70,7 @@ interface SessionLauncherCardProps { toggleSessionView?: () => void; openSessionViewWithJob?: (submissionId: string) => void; toggleEnvVariables?: () => void; + toggleLogsHistory?: () => void; } export default function SessionLauncherCard({ launcher, @@ -82,6 +84,7 @@ export default function SessionLauncherCard({ toggleShareLink, openSessionViewWithJob, toggleEnvVariables, + toggleLogsHistory, }: SessionLauncherCardProps) { const { builds, @@ -118,7 +121,8 @@ export default function SessionLauncherCard({ (toggleUpdate || toggleDelete || toggleShareLink || - toggleUpdateEnvironment) && ( + toggleUpdateEnvironment || + toggleLogsHistory) && ( ); @@ -374,6 +379,7 @@ interface LauncherDropdownActionsProps { toggleUpdateEnvironment?: () => void; toggleShareLink?: () => void; toggleEnvVariables?: () => void; + toggleLogsHistory?: () => void; project: Project; } function LauncherDropdownActions({ @@ -383,6 +389,7 @@ function LauncherDropdownActions({ toggleUpdateEnvironment, toggleShareLink, toggleEnvVariables, + toggleLogsHistory, }: LauncherDropdownActionsProps) { const { project_id: projectId } = launcher; const permissions = useProjectPermissions({ projectId }); @@ -432,6 +439,15 @@ function LauncherDropdownActions({ Share session launch link )} + {toggleLogsHistory && ( + + + View logs history + + )} {toggleDelete && ( <> diff --git a/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx b/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx index a0f233a421..70c6b5466e 100644 --- a/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx @@ -18,6 +18,7 @@ import { useCallback, useMemo, useState } from "react"; +import SessionsPersistedLogsHistoryModal from "~/features/persistedLogs/SessionsPersistedLogsHistoryModal"; import useLocationHash from "../../../utils/customHooks/useLocationHash.hook"; import { Project } from "../../projectsV2/api/projectV2.api"; import type { SessionLauncher } from "../api/sessionLaunchersV2.api"; @@ -52,6 +53,7 @@ export function SessionLauncherDisplay({ const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isShareLinkOpen, setIsShareLinkOpen] = useState(false); const [isEnvVariablesOpen, setEnvVariablesOpen] = useState(false); + const [isLogsHistoryOpen, setIsLogsHistoryOpen] = useState(false); const toggleUpdate = useCallback(() => { setIsUpdateOpen((open) => !open); @@ -68,6 +70,9 @@ export function SessionLauncherDisplay({ const toggleEnvVariables = useCallback(() => { setEnvVariablesOpen((open) => !open); }, []); + const toggleLogsHistory = useCallback(() => { + setIsLogsHistoryOpen((open) => !open); + }, []); const [hash, setHash] = useLocationHash(); const launcherHash = useMemo( @@ -133,6 +138,7 @@ export function SessionLauncherDisplay({ toggleSessionView={toggleSessionView} openSessionViewWithJob={openSessionViewWithJob} toggleEnvVariables={toggleEnvVariables} + toggleLogsHistory={toggleLogsHistory} /> + )} diff --git a/client/src/features/sessionsV2/SessionView/SessionView.tsx b/client/src/features/sessionsV2/SessionView/SessionView.tsx index 9357c840bc..651e9583b6 100644 --- a/client/src/features/sessionsV2/SessionView/SessionView.tsx +++ b/client/src/features/sessionsV2/SessionView/SessionView.tsx @@ -237,6 +237,7 @@ interface SessionViewProps { toggleDelete?: () => void; toggleUpdateEnvironment?: () => void; toggleEnvVariables?: () => void; + toggleLogsHistory?: () => void; } export function SessionView({ id, @@ -250,6 +251,7 @@ export function SessionView({ toggleUpdate, toggleUpdateEnvironment, toggleEnvVariables, + // toggleLogsHistory, }: SessionViewProps) { const [isUpdateOpen, setIsUpdateOpen] = useState(false); const [isModifyResourcesOpen, setModifyResourcesOpen] = useState(false); diff --git a/client/src/store/store.ts b/client/src/store/store.ts index 8333d0b517..595a5e273c 100644 --- a/client/src/store/store.ts +++ b/client/src/store/store.ts @@ -29,6 +29,7 @@ import dataConnectorFormSlice from "~/features/dataConnectorsV2/state/dataConnec import { displaySlice } from "~/features/display/displaySlice"; import { loginStateSlice } from "~/features/loginHandler/loginState.slice"; import { notificationsEmptyApi as notificationsApi } from "~/features/notifications/api/notifications.empty-api"; +import { persistedLogsEmptyApi as persistedLogsApi } from "~/features/persistedLogs/api/persistedLogs.empty-api"; import { platformEmptyApi as platformApi } from "~/features/platform/api/platform-empty.api"; import { statuspageEmptyApi as statuspageApi } from "~/features/platform/statuspage-api/statuspage-empty.api"; import { projectV2Api } from "~/features/projectsV2/api/projectV2.enhanced-api"; @@ -63,6 +64,7 @@ export const store = configureStore({ [dataConnectorsApi.reducerPath]: dataConnectorsApi.reducer, [doiResolverApi.reducerPath]: doiResolverApi.reducer, [notificationsApi.reducerPath]: notificationsApi.reducer, + [persistedLogsApi.reducerPath]: persistedLogsApi.reducer, [platformApi.reducerPath]: platformApi.reducer, [projectCloudStorageApi.reducerPath]: projectCloudStorageApi.reducer, [projectV2Api.reducerPath]: projectV2Api.reducer, @@ -87,6 +89,7 @@ export const store = configureStore({ .concat(dataConnectorsApi.middleware) .concat(doiResolverApi.middleware) .concat(notificationsApi.middleware) + .concat(persistedLogsApi.middleware) .concat(platformApi.middleware) .concat(projectCloudStorageApi.middleware) .concat(projectV2Api.middleware)