diff --git a/client/src/components/TimeCaption.tsx b/client/src/components/TimeCaption.tsx index 1706aee39f..3b97d8466f 100644 --- a/client/src/components/TimeCaption.tsx +++ b/client/src/components/TimeCaption.tsx @@ -34,6 +34,7 @@ interface TimeCaptionProps { noCaption?: boolean; prefix?: ReactNode; suffix?: ReactNode; + format?: "long" | "short"; } export function TimeCaption({ @@ -43,13 +44,14 @@ export function TimeCaption({ noCaption, prefix, suffix, + format = "long", }: TimeCaptionProps) { const [now, setNow] = useState(DateTime.utc()); const datetime = datetime_ ? ensureDateTime(datetime_) : null; const durationStr = datetime != null && datetime.isValid - ? toHumanRelativeDuration({ datetime, now }) + ? toHumanRelativeDuration({ datetime, now, format }) : "at unknown time"; const className = noCaption ? className_ : cx("time-caption", className_); diff --git a/client/src/components/entities/entities.types.ts b/client/src/components/entities/entities.types.ts index 23f4c121f4..528017b550 100644 --- a/client/src/components/entities/entities.types.ts +++ b/client/src/components/entities/entities.types.ts @@ -1,4 +1,5 @@ export type EntityTypes = | "code-repository" | "data-connector" + | "job-launcher" | "session-launcher"; diff --git a/client/src/components/offcanvas/OffcanvasHeaderWithType.tsx b/client/src/components/offcanvas/OffcanvasHeaderWithType.tsx index 0454511b50..3248f46312 100644 --- a/client/src/components/offcanvas/OffcanvasHeaderWithType.tsx +++ b/client/src/components/offcanvas/OffcanvasHeaderWithType.tsx @@ -2,6 +2,7 @@ import cx from "classnames"; import { Database, FileCode, + Gear, PlayCircle, QuestionCircle, } from "react-bootstrap-icons"; @@ -29,6 +30,8 @@ export default function OffcanvasHeaderWithType({ ) : entityType === "session-launcher" ? ( + ) : entityType === "job-launcher" ? ( + ) : entityType === "code-repository" ? ( ) : ( @@ -39,11 +42,13 @@ export default function OffcanvasHeaderWithType({ ? _entityName : entityType === "data-connector" ? "Data connector" - : entityType === "session-launcher" - ? "Session launcher" - : entityType === "code-repository" - ? "Code repository" - : "Unknown"; + : entityType === "job-launcher" + ? "Job launcher" + : entityType === "session-launcher" + ? "Session launcher" + : entityType === "code-repository" + ? "Code repository" + : "Unknown"; return (
diff --git a/client/src/features/ProjectPageV2/utils/useProjectPermissions.hook.ts b/client/src/features/ProjectPageV2/utils/useProjectPermissions.hook.ts index c74b374b3a..c78ddbe9cd 100644 --- a/client/src/features/ProjectPageV2/utils/useProjectPermissions.hook.ts +++ b/client/src/features/ProjectPageV2/utils/useProjectPermissions.hook.ts @@ -20,7 +20,7 @@ import { skipToken } from "@reduxjs/toolkit/query"; import { useEffect } from "react"; import { DEFAULT_PERMISSIONS } from "../../permissionsV2/permissions.constants"; -import type { Permissions } from "../../permissionsV2/permissions.types"; +import type { ProjectPermissions } from "../../permissionsV2/permissions.types"; import { projectV2Api } from "../../projectsV2/api/projectV2.enhanced-api"; interface UseProjectPermissionsArgs { @@ -29,7 +29,7 @@ interface UseProjectPermissionsArgs { export default function useProjectPermissions({ projectId, -}: UseProjectPermissionsArgs): Permissions { +}: UseProjectPermissionsArgs): ProjectPermissions { const { currentData, isLoading, isError, isUninitialized } = projectV2Api.endpoints.getProjectsByProjectIdPermissions.useQueryState( projectId ? { projectId } : skipToken, @@ -43,13 +43,22 @@ export default function useProjectPermissions({ } }, [fetchPermissions, isUninitialized, projectId]); + const isLoadingPermissions = isLoading || isUninitialized; + const arePermissionsResolved = + !isLoading && !isUninitialized && !isError && currentData != null; + if (isLoading || isError || !currentData) { - return DEFAULT_PERMISSIONS; + return { + ...DEFAULT_PERMISSIONS, + arePermissionsResolved: false, + isLoadingPermissions, + }; } - const permissions: Permissions = { + return { ...DEFAULT_PERMISSIONS, ...currentData, + arePermissionsResolved, + isLoadingPermissions: false, }; - return permissions; } diff --git a/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx b/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx index d56bc897d8..5958e2852a 100644 --- a/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx +++ b/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx @@ -70,6 +70,7 @@ export default function SessionEnvironmentAdvancedFields({ control={control} errors={errors} + launcherCategory="session" /> diff --git a/client/src/features/admin/UpdateSessionEnvironmentButton.tsx b/client/src/features/admin/UpdateSessionEnvironmentButton.tsx index 3e61004583..2102fb5814 100644 --- a/client/src/features/admin/UpdateSessionEnvironmentButton.tsx +++ b/client/src/features/admin/UpdateSessionEnvironmentButton.tsx @@ -106,10 +106,8 @@ function UpdateSessionEnvironmentModal({ uid: data.uid ?? undefined, working_directory: data.working_directory?.trim() || undefined, strip_path_prefix: data.strip_path_prefix, - ...(commandParsed.data - ? { command: commandParsed.data } - : { command: null }), - ...(argsParsed.data ? { args: argsParsed.data } : { args: null }), + command: commandParsed.data ?? undefined, + args: argsParsed.data ?? undefined, }, }); }, diff --git a/client/src/features/dashboardV2/DashboardV2.tsx b/client/src/features/dashboardV2/DashboardV2.tsx index 8efda874af..10c962b6e5 100644 --- a/client/src/features/dashboardV2/DashboardV2.tsx +++ b/client/src/features/dashboardV2/DashboardV2.tsx @@ -25,6 +25,7 @@ import { Eye, FileEarmarkText, Folder, + Gear, Megaphone, People, PlayCircle, @@ -65,6 +66,7 @@ import CreateProjectV2Button from "../projectsV2/new/CreateProjectV2Button"; import GroupShortHandDisplay from "../projectsV2/show/GroupShortHandDisplay"; import ProjectShortHandDisplay from "../projectsV2/show/ProjectShortHandDisplay"; import { useGetSessionsQuery as useGetSessionsQueryV2 } from "../sessionsV2/api/sessionsV2.api"; +import { SESSION_LAUNCHER_KIND } from "../sessionsV2/sessionsV2.types"; import { useGetUserQueryState } from "../usersV2/api/users.api"; import UserAvatar from "../usersV2/show/UserAvatar"; import DashboardV2Sessions from "./DashboardV2Sessions"; @@ -96,6 +98,7 @@ export default function DashboardV2() { className={cx("d-flex", "flex-column", "gap-4")} > + @@ -422,7 +425,11 @@ function GroupsList({ data, error, isLoading }: GroupListProps) { } function SessionsDashboard() { - const { data: sessions, error, isLoading } = useGetSessionsQueryV2({}); + const { + data: sessions, + error, + isLoading, + } = useGetSessionsQueryV2({ sessionType: SESSION_LAUNCHER_KIND.INTERACTIVE }); const totalSessions = sessions ? sessions?.length : 0; return ( @@ -447,6 +454,38 @@ function SessionsDashboard() { ); } +function JobsDashboard() { + const { + data: jobs, + error, + isLoading, + } = useGetSessionsQueryV2({ + sessionType: SESSION_LAUNCHER_KIND.NON_INTERACTIVE, + }); + const totalJobs = jobs ? jobs?.length : 0; + return ( + + +
+

+ + My Jobs +

+ {totalJobs} +
+
+ + + + +
+ ); +} + function ViewAllLink({ type, noItems, diff --git a/client/src/features/dashboardV2/DashboardV2Sessions.tsx b/client/src/features/dashboardV2/DashboardV2Sessions.tsx index cad92358f8..86452b5146 100644 --- a/client/src/features/dashboardV2/DashboardV2Sessions.tsx +++ b/client/src/features/dashboardV2/DashboardV2Sessions.tsx @@ -22,6 +22,7 @@ import cx from "classnames"; import { generatePath, Link } from "react-router"; import { Col, ListGroup, Row } from "reactstrap"; +import { sessionLauncherKindToCategory } from "~/features/sessionsV2/session.utils"; import RtkOrDataServicesError from "../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../components/Loader"; import { ABSOLUTE_ROUTES } from "../../routing/routes.constants"; @@ -30,10 +31,15 @@ import { useGetSessionLaunchersByLauncherIdQuery as useGetProjectSessionLauncher import ActiveSessionButton from "../sessionsV2/components/SessionButton/ActiveSessionButton"; import { getSessionStatusStyles, + SessionStatusV2Badge, SessionStatusV2Description, SessionStatusV2Label, } from "../sessionsV2/components/SessionStatus/SessionStatus"; -import { SessionList, SessionV2 } from "../sessionsV2/sessionsV2.types"; +import { + SESSION_LAUNCHER_KIND, + SessionList, + SessionV2, +} from "../sessionsV2/sessionsV2.types"; import styles from "./DashboardV2Sessions.module.scss"; @@ -100,16 +106,13 @@ function SessionDashboardList({ return ( {sessions?.map((session) => ( - + ))} ); } -interface DashboardSessionProps { - session: SessionV2; -} -function DashboardSession({ session }: DashboardSessionProps) { +function useDashboardSessionItem(session: SessionV2) { const { project_id: projectId, launcher_id: launcherId } = session; const { data: project } = useGetProjectsByProjectIdQuery( projectId ? { projectId } : skipToken, @@ -136,9 +139,44 @@ function DashboardSession({ session }: DashboardSessionProps) { }) : ABSOLUTE_ROUTES.v2.index; - const sessionStyles = getSessionStatusStyles(session); + return { launcher, project, projectId, projectUrl, showSessionUrl }; +} + +function DashboardSessionStatusRow({ session }: { session: SessionV2 }) { + const launcherCategory = sessionLauncherKindToCategory(session.session_type); + const sessionStyles = getSessionStatusStyles(session, launcherCategory); const state = session.status.state; + return ( +
+ {`Session + +
+ ); +} + +function DashboardJobStatusRow({ session }: { session: SessionV2 }) { + return ( +
+ + Job: {session.submission_id} + + +
+ ); +} + +function DashboardSessionListItem({ session }: { session: SessionV2 }) { + const { launcher, project, projectId, projectUrl, showSessionUrl } = + useDashboardSessionItem(session); + const isJob = session.session_type === SESSION_LAUNCHER_KIND.NON_INTERACTIVE; + return (
-
- {`Session - -
+ {isJob ? ( + + ) : ( + + )} diff --git a/client/src/features/logsDisplay/LogsModal.tsx b/client/src/features/logsDisplay/LogsModal.tsx index 196515147b..76190d8ad0 100644 --- a/client/src/features/logsDisplay/LogsModal.tsx +++ b/client/src/features/logsDisplay/LogsModal.tsx @@ -100,10 +100,13 @@ export default function LogsModal({ "fs-4", "fst-italic", "mb-0", - getSessionStatusStyles({ - status: { state: sessionState }, - image: "url", - })["textColorCard"], + getSessionStatusStyles( + { + status: { state: sessionState }, + image: "url", + }, + "session", + )["textColorCard"], )} > Session status: {sessionState} diff --git a/client/src/features/permissionsV2/permissions.types.ts b/client/src/features/permissionsV2/permissions.types.ts index e8cd358fdb..1fb8b82a04 100644 --- a/client/src/features/permissionsV2/permissions.types.ts +++ b/client/src/features/permissionsV2/permissions.types.ts @@ -21,3 +21,8 @@ export type RequestedPermission = "write" | "delete" | "change_membership"; export type Permissions = { [key in RequestedPermission]: boolean; }; + +export type ProjectPermissions = Permissions & { + arePermissionsResolved: boolean; + isLoadingPermissions: boolean; +}; diff --git a/client/src/features/sessionsV2/AddSessionLauncherButton.tsx b/client/src/features/sessionsV2/AddSessionLauncherButton.tsx index d71fafc823..f1d263fcfa 100644 --- a/client/src/features/sessionsV2/AddSessionLauncherButton.tsx +++ b/client/src/features/sessionsV2/AddSessionLauncherButton.tsx @@ -21,7 +21,7 @@ import { useCallback, useState } from "react"; import { PlusLg } from "react-bootstrap-icons"; import { Button } from "reactstrap"; -import NewSessionLauncherModal from "./components/SessionModals/NewSessionLauncherModal"; +import NewLauncherModal from "./components/SessionModals/NewLauncherModal"; export default function AddSessionLauncherButton({ "data-cy": dataCy, @@ -38,9 +38,9 @@ export default function AddSessionLauncherButton({ return ( <> {styleBtn === "iconTextBtn" ? ( - ) : ( )} - + ); } diff --git a/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx b/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx index 9c675d2003..33dafb74e9 100644 --- a/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx +++ b/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx @@ -59,6 +59,13 @@ const CONTEXT_STRINGS = { testError: "The data connector could not be mounted. Please retry with different credentials, or skip the data connector. If you skip, the data connector will not be mounted in the session.", }, + job: { + continueButton: "Continue", + dataCy: "job-data-connector-credentials-modal", + header: "Job Storage Credentials", + testError: + "The data connector could not be mounted. Please retry with different credentials, or skip the data connector. If you skip, the data connector will not be mounted in the job.", + }, storage: { continueButton: "Test and Save", dataCy: "data-connector-credentials-modal", @@ -158,7 +165,7 @@ function DataConnectorSecrets({ } interface DataConnectorSecretsModalProps { - context?: "session" | "storage"; + context?: "session" | "job" | "storage"; isOpen: boolean; onCancel: () => void; onStart: (dataConnectorConfigs: DataConnectorConfiguration[]) => void; @@ -352,7 +359,9 @@ function CredentialsButtons({ Cancel - {context === "session" && } + {(context === "session" || context === "job") && ( + + )} {context === "storage" && ( ) { +}: Pick) { const skipButtonRef = useRef(null); + const targetLabel = context === "job" ? "job" : "session"; return ( <> @@ -624,7 +635,7 @@ function SkipConnectionTestButton({ - Skip the data connector. It will not be mounted in the session. + {`Skip the data connector. It will not be mounted in the ${targetLabel}.`} ); diff --git a/client/src/features/sessionsV2/DeleteSessionLauncherModal.tsx b/client/src/features/sessionsV2/DeleteSessionLauncherModal.tsx index 2e542853f6..728dccd057 100644 --- a/client/src/features/sessionsV2/DeleteSessionLauncherModal.tsx +++ b/client/src/features/sessionsV2/DeleteSessionLauncherModal.tsx @@ -25,6 +25,10 @@ import { WarnAlert } from "../../components/Alert"; import RtkOrDataServicesError from "../../components/errors/RtkOrDataServicesError"; import type { SessionLauncher } from "./api/sessionLaunchersV2.api"; import { useDeleteSessionLaunchersByLauncherIdMutation as useDeleteSessionLauncherMutation } from "./api/sessionLaunchersV2.api"; +import { + getLauncherCategory, + getLauncherCategoryDefinition, +} from "./session.utils"; interface DeleteSessionLauncherModalProps { isOpen: boolean; @@ -40,6 +44,8 @@ export default function DeleteSessionLauncherModal({ sessionsLength, }: DeleteSessionLauncherModalProps) { const [deleteSessionLauncher, result] = useDeleteSessionLauncherMutation(); + const launcherCategory = getLauncherCategory(launcher); + const launcherDefinition = getLauncherCategoryDefinition(launcherCategory); const onDelete = useCallback(() => { deleteSessionLauncher({ @@ -75,32 +81,37 @@ export default function DeleteSessionLauncherModal({ tag="h2" toggle={toggle} > - Delete session launcher + Delete {launcherDefinition.text.inline} launcher {result.error && }

- Are you sure you want to delete the {launcher.name} session - launcher? + Are you sure you want to delete the {launcher.name}{" "} + {launcherDefinition.text.inline} launcher?

{sessionsLength > 0 && (

- You have a session running from this launcher. If you delete this - session launcher, that session will continue running, but it - become an orphan session and will not be able to be launched again - once stopped. If other RenkuLab users are running sessions from - this launcher, their sessions will become orphan sessions as well. + You have a {launcherDefinition.text.inline} running from this + launcher. If you delete this launcher, that{" "} + {launcherDefinition.text.inline} will continue running, but it + become an orphan {launcherDefinition.text.inline} and will not be + able to be launched again once stopped. If other RenkuLab users + are running {launcherDefinition.text.inline}s from this launcher, + their {launcherDefinition.text.inline}s will become orphan{" "} + {launcherDefinition.text.inline}s as well.

)} {sessionsLength === 0 && (

- If other RenkuLab users are running sessions from this launcher, - their sessions will become orphan sessions. This means that their - sessions will continue running, but will not be able to be - launched again once stopped. + If other RenkuLab users are running{" "} + {launcherDefinition.text.inline}s from this launcher, their{" "} + {launcherDefinition.text.inline}s will become orphan{" "} + {launcherDefinition.text.inline}s. This means that their{" "} + {launcherDefinition.text.inline}s will continue running, but will + not be able to be launched again once stopped.

)} @@ -119,7 +130,7 @@ export default function DeleteSessionLauncherModal({ role="button" > - Delete Session launcher + Delete {launcherDefinition.text.inline} launcher diff --git a/client/src/features/sessionsV2/SaveCloudStorageCredentials.tsx b/client/src/features/sessionsV2/SaveCloudStorageCredentials.tsx new file mode 100644 index 0000000000..4844c9b26c --- /dev/null +++ b/client/src/features/sessionsV2/SaveCloudStorageCredentials.tsx @@ -0,0 +1,184 @@ +/*! + * 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 cx from "classnames"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import RtkOrDataServicesError from "~/components/errors/RtkOrDataServicesError"; +import ProgressStepsIndicator, { + ProgressStyle, + ProgressType, + StatusStepProgressBar, + StepsProgressBar, +} from "~/components/progress/ProgressSteps"; +import { storageDefinitionAfterSavingCredentialsFromConfig } from "../cloudStorage/projectCloudStorage.utils"; +import { usePatchDataConnectorsByDataConnectorIdSecretsMutation } from "../dataConnectorsV2/api/data-connectors.enhanced-api"; +import { shouldCloudStorageSaveCredentials } from "./sessionLaunchValidation.utils"; +import type { SessionStartDataConnectorConfiguration } from "./startSessionOptionsV2.types"; + +import progressBoxStyles from "~/components/progress/ProgressBox.module.scss"; + +interface SaveCloudStorageCredentialsProps { + dataConnectors: SessionStartDataConnectorConfiguration[]; + onComplete: (configs: SessionStartDataConnectorConfiguration[]) => void; + title?: string; +} + +export default function SaveCloudStorageCredentials({ + dataConnectors, + onComplete, + title = "Saving credentials", +}: SaveCloudStorageCredentialsProps) { + const [steps, setSteps] = useState([]); + const [saveCredentials, saveCredentialsResult] = + usePatchDataConnectorsByDataConnectorIdSecretsMutation(); + + const credentialsToSave = useMemo(() => { + return dataConnectors + .filter(shouldCloudStorageSaveCredentials) + .map((cs) => ({ + storageName: cs.dataConnector.name, + storageId: cs.dataConnector.id, + secrets: cs.sensitiveFieldValues, + })); + }, [dataConnectors]); + + const [results, setResults] = useState( + credentialsToSave.map(() => StatusStepProgressBar.WAITING), + ); + + const [index, setIndex] = useState(0); + const [hasFailed, setHasFailed] = useState(false); + const [failedError, setFailedError] = + useState(undefined); + const hasCompletedRef = useRef(false); + + useEffect(() => { + const theSteps = credentialsToSave.map((cs, i) => ({ + id: i, + status: results[i], + step: `Saving credentials for ${cs.storageName}`, + })); + // TODO: fix react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect + setSteps(theSteps); + }, [credentialsToSave, results]); + + useEffect(() => { + if ( + hasFailed || + credentialsToSave.length < 1 || + index >= credentialsToSave.length + ) + return; + // TODO: fix react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect + setResults((prev) => { + const newResults = [...prev]; + newResults[index] = StatusStepProgressBar.EXECUTING; + return newResults; + }); + const storage = credentialsToSave[index]; + saveCredentials({ + dataConnectorId: storage.storageId, + dataConnectorSecretPatchList: Object.entries(storage.secrets).map( + ([key, value]) => ({ + name: key, + value, + }), + ), + }); + }, [credentialsToSave, hasFailed, index, saveCredentials]); + + useEffect(() => { + if ( + saveCredentialsResult.isUninitialized || + saveCredentialsResult.isLoading + ) + return; + if (saveCredentialsResult.data != null) { + // TODO: fix react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect + setResults((prev) => { + const newResults = [...prev]; + newResults[index] = StatusStepProgressBar.READY; + return newResults; + }); + saveCredentialsResult.reset(); + setIndex((prev) => prev + 1); + return; + } + if (saveCredentialsResult.error != null) { + setResults((prev) => { + const newResults = [...prev]; + newResults[index] = StatusStepProgressBar.FAILED; + return newResults; + }); + setFailedError(saveCredentialsResult.error); + setHasFailed(true); + saveCredentialsResult.reset(); + } + }, [index, saveCredentialsResult]); + + useEffect(() => { + if ( + hasFailed || + saveCredentialsResult.isLoading || + hasCompletedRef.current + ) { + return; + } + if (index >= credentialsToSave.length) { + hasCompletedRef.current = true; + const cloudStorageConfigs = dataConnectors.map((cs) => + storageDefinitionAfterSavingCredentialsFromConfig(cs), + ); + onComplete(cloudStorageConfigs); + } + }, [ + credentialsToSave.length, + dataConnectors, + hasFailed, + index, + onComplete, + saveCredentialsResult.isLoading, + ]); + + return ( +
+ {hasFailed && failedError != null && ( + + )} + +
+ ); +} diff --git a/client/src/features/sessionsV2/SessionList/JobCard.tsx b/client/src/features/sessionsV2/SessionList/JobCard.tsx new file mode 100644 index 0000000000..364cea8955 --- /dev/null +++ b/client/src/features/sessionsV2/SessionList/JobCard.tsx @@ -0,0 +1,156 @@ +/*! + * 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 cx from "classnames"; +import { KeyboardEvent, MouseEvent, useCallback } from "react"; +import { Col, Row } from "reactstrap"; + +import { + getLauncherCategoryDefinition, + sessionLauncherKindToCategory, +} from "~/features/sessionsV2/session.utils"; +import { Project } from "../../projectsV2/api/projectV2.api"; +import ActiveSessionButton from "../components/SessionButton/ActiveSessionButton"; +import { + SessionStatusV2Badge, + SessionStatusV2Description, +} from "../components/SessionStatus/SessionStatus"; +import { getShowSessionUrlByProject } from "../SessionsV2"; +import { SessionV2 } from "../sessionsV2.types"; + +interface JobCardProps { + project: Project; + session?: SessionV2; + onOpen?: (submissionId: string) => void; +} +export default function JobCard({ project, session, onOpen }: JobCardProps) { + const handleOpen = useCallback(() => { + if (session?.submission_id && onOpen) { + onOpen(session.submission_id); + } + }, [onOpen, session]); + + const stopPropagation = useCallback((event: MouseEvent | KeyboardEvent) => { + event.stopPropagation(); + }, []); + + if (!session) return null; + + const launcherCategory = sessionLauncherKindToCategory(session.session_type); + const launcherDefinition = getLauncherCategoryDefinition(launcherCategory); + + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleOpen(); + } + } + : undefined + } + role={onOpen ? "button" : undefined} + aria-label={ + onOpen + ? `Open job with submission id ${session.submission_id} details` + : undefined + } + tabIndex={onOpen ? 0 : undefined} + > +
+ + + + {launcherDefinition.text.display}: {session.submission_id} + + + + +
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/client/src/features/sessionsV2/SessionList/SessionCard.tsx b/client/src/features/sessionsV2/SessionList/SessionCard.tsx index eb8a767673..07368fda83 100644 --- a/client/src/features/sessionsV2/SessionList/SessionCard.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionCard.tsx @@ -19,6 +19,10 @@ import cx from "classnames"; import { Col, Row } from "reactstrap"; +import { + getLauncherCategoryDefinition, + sessionLauncherKindToCategory, +} from "~/features/sessionsV2/session.utils"; import { Project } from "../../projectsV2/api/projectV2.api"; import ActiveSessionButton from "../components/SessionButton/ActiveSessionButton"; import { @@ -38,7 +42,9 @@ interface SessionCardProps { export default function SessionCard({ project, session }: SessionCardProps) { if (!session) return null; - const stylesPerSession = getSessionStatusStyles(session); + const launcherCategory = sessionLauncherKindToCategory(session.session_type); + const stylesPerSession = getSessionStatusStyles(session, launcherCategory); + const launcherDefinition = getLauncherCategoryDefinition(launcherCategory); return (
- Session line indicator -
+ {launcherCategory === "session" && ( + Session line indicator + )} +
@@ -71,7 +85,10 @@ export default function SessionCard({ project, session }: SessionCardProps) { )} > - Session + + {launcherDefinition.text.display} + + {session.submission_id ? `: ${session.submission_id}` : ""} void; toggleShareLink?: () => void; toggleSessionView?: () => void; + openSessionViewWithJob?: (submissionId: string) => void; } export default function SessionLauncherCard({ launcher, @@ -73,6 +86,7 @@ export default function SessionLauncherCard({ toggleUpdateEnvironment, toggleSessionView, toggleShareLink, + openSessionViewWithJob, }: SessionLauncherCardProps) { const { params } = useContext(AppContext); const environment = launcher?.environment; @@ -94,6 +108,14 @@ export default function SessionLauncherCard({ (build) => build.status === "succeeded" && build.id !== lastBuild?.id, ); const hasSession = !!sessions?.length; + const sessionType = sessions?.at(0)?.session_type ?? "interactive"; + // Orphan sessions have no launcher; get category from the session itself + const launcherCategory = sessionLauncherKindToCategory( + launcher?.launcher_type || sessionType, + ); + const launcherDefinition = getLauncherCategoryDefinition(launcherCategory); + const LauncherTypeIcon = launcherDefinition?.icon || PlayCircle; + const launcherTypeLabel = launcherDefinition?.text.display || null; sessionLaunchersV2Api.endpoints.getEnvironmentsByEnvironmentIdBuilds.useQuerySubscription( isCodeEnvironment && lastBuild?.status === "in_progress" @@ -105,11 +127,11 @@ export default function SessionLauncherCard({ ); const otherLauncherActions = launcher && - toggleUpdate && - toggleDelete && - toggleShareLink && - toggleUpdateEnvironment && ( - -
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleSessionView?.(); + } + }} + role="button" + tabIndex={0} + > - - - - Session Launcher - - - - {environment?.environment_kind === "GLOBAL" ? ( - - - Global environment - - ) : isCodeEnvironment ? ( - - - Code based environment - - ) : isExternalImageEnvironment ? ( - - - External image environment + {launcher && ( + + + + + {launcherTypeLabel}{" "} + Launcher - ) : null} - - + + + {environment?.environment_kind === "GLOBAL" ? ( + + + Global environment + + ) : isCodeEnvironment ? ( + + + Code based environment + + ) : isExternalImageEnvironment ? ( + + + External image environment + + ) : null} + + + )} - {sessions && sessions?.length > 0 && - sessions.map((session) => ( - - ))} + sessions.map((session) => { + if (session.session_type === "interactive") + return ( + + ); + return ( + + ); + })}
)} @@ -345,62 +407,72 @@ export default function SessionLauncherCard({ ); } -interface SessionLauncherDropdownActionsProps { +interface LauncherDropdownActionsProps { launcher: SessionLauncher; - toggleUpdate: () => void; - toggleDelete: () => void; - toggleUpdateEnvironment: () => void; - toggleShareLink: () => void; + toggleUpdate?: () => void; + toggleDelete?: () => void; + toggleUpdateEnvironment?: () => void; + toggleShareLink?: () => void; project: Project; } -function SessionLauncherDropdownActions({ +function LauncherDropdownActions({ launcher, toggleDelete, toggleUpdate, toggleUpdateEnvironment, toggleShareLink, -}: SessionLauncherDropdownActionsProps) { +}: LauncherDropdownActionsProps) { const { project_id: projectId } = launcher; const permissions = useProjectPermissions({ projectId }); - + const launcherCategory = getLauncherCategory(launcher); return ( <> - - - Edit environment - - - - Edit launcher - - - - Share session launch link - - - - - Delete launcher - + {toggleUpdateEnvironment && ( + + + Edit environment + + )} + {toggleUpdate && ( + + + Edit launcher + + )} + {toggleShareLink && launcherCategory === "session" && ( + + + Share session launch link + + )} + {toggleDelete && ( + <> + + + + Delete launcher + + + )} } requestedPermission="write" diff --git a/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx b/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx index 394b14c278..e14a15b526 100644 --- a/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx @@ -25,6 +25,14 @@ import { useGetSessionsQuery as useGetSessionsQueryV2 } from "../api/sessionsV2. import UpdateSessionLauncherMetadataModal from "../components/SessionModals/UpdateSessionLauncherMetadataModal"; import UpdateSessionLauncherEnvironmentModal from "../components/SessionModals/UpdateSessionLauncherModal"; import DeleteSessionV2Modal from "../DeleteSessionLauncherModal"; +import { + buildLauncherHash, + buildLauncherJobHash, + isLauncherHashOpen, + parseLauncherHash, + toggleLauncherHash, +} from "../session.utils"; +import { SESSION_LAUNCHER_KIND } from "../sessionsV2.types"; import SessionLaunchLinkModal from "../SessionView/SessionLaunchLinkModal"; import { SessionView } from "../SessionView/SessionView"; import SessionLauncherCard from "./SessionLauncherCard"; @@ -57,19 +65,37 @@ export function SessionLauncherDisplay({ }, []); const [hash, setHash] = useLocationHash(); - const launcherHash = useMemo(() => `launcher-${launcher.id}`, [launcher.id]); + const launcherHash = useMemo( + () => buildLauncherHash(launcher.id), + [launcher.id], + ); const isSessionViewOpen = useMemo( - () => hash === launcherHash, - [hash, launcherHash], + () => isLauncherHashOpen(hash, launcher.id), + [hash, launcher.id], ); + const openJobSubmissionId = useMemo(() => { + const parsed = parseLauncherHash(hash); + if (parsed.launcherId !== launcher.id) { + return undefined; + } + return parsed.submissionId; + }, [hash, launcher.id]); const toggleSessionView = useCallback(() => { - setHash((prev) => { - const isOpen = prev === launcherHash; - return isOpen ? "" : launcherHash; - }); - }, [launcherHash, setHash]); + setHash((prev) => toggleLauncherHash(prev, launcher.id)); + }, [launcher.id, setHash]); + const closeSessionView = useCallback(() => { + setHash((prev) => (isLauncherHashOpen(prev, launcher.id) ? "" : prev)); + }, [launcher.id, setHash]); + const openSessionViewWithJob = useCallback( + (submissionId: string) => { + setHash(buildLauncherJobHash(launcher.id, submissionId)); + }, + [launcher.id, setHash], + ); - const { data: sessions } = useGetSessionsQueryV2({}); + const { data: sessions } = useGetSessionsQueryV2({ + sessionType: launcher.launcher_type, + }); const filteredSessions = useMemo( () => @@ -94,16 +120,22 @@ export function SessionLauncherDisplay({ toggleUpdate={toggleUpdate} toggleUpdateEnvironment={toggleUpdateEnvironment} toggleDelete={toggleDelete} - toggleShareLink={toggleShareLink} + toggleShareLink={ + launcher.launcher_type === SESSION_LAUNCHER_KIND.INTERACTIVE + ? toggleShareLink + : undefined + } toggleSessionView={toggleSessionView} + openSessionViewWithJob={openSessionViewWithJob} /> void; + onCancel?: () => void; + continueLabel?: string; + title?: string; + warningIntro?: string; } export default function SessionRepositoriesModal({ isOpen, project, + onSkip: onSkipProp, + onCancel: onCancelProp, + continueLabel = "Launch anyway", + title = "Session repositories not accessible", + warningIntro = "your attention before launching the session", }: SessionRepositoriesModalProps) { const navigate = useNavigate(); - const onCancel = useCallback(() => { + const defaultOnCancel = useCallback(() => { const url = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { namespace: project.namespace, slug: project.slug, }); navigate(url); }, [navigate, project.namespace, project.slug]); + const onCancel = onCancelProp ?? defaultOnCancel; const projectPermissions = useProjectPermissions({ projectId: project.id }); const { data, error, isLoading } = useGetRepositoriesQuery( @@ -85,9 +96,10 @@ export default function SessionRepositoriesModal({ }, [data, isLoading, projectPermissions?.write]); const dispatch = useAppDispatch(); - const onSkip = useCallback(() => { + const defaultOnSkip = useCallback(() => { dispatch(startSessionOptionsV2Slice.actions.setRepositoriesReady(true)); }, [dispatch]); + const onSkip = onSkipProp ?? defaultOnSkip; const content = isLoading || !data ? ( @@ -107,7 +119,7 @@ export default function SessionRepositoriesModal({ {repoWithInterruptions.length === 1 ? `is ${repoWithInterruptions.length} repository that requires` : `are ${repoWithInterruptions.length} repositories that require`}{" "} - your attention before launching the session: + your attention {warningIntro}:

{repoWithInterruptions.map((repository) => ( @@ -129,7 +141,7 @@ export default function SessionRepositoriesModal({ isOpen={isOpen} size="lg" > - Session repositories not accessible + {title} {content} diff --git a/client/src/features/sessionsV2/SessionSecretsModal.tsx b/client/src/features/sessionsV2/SessionSecretsModal.tsx index cfa38e8b59..fbf95c560e 100644 --- a/client/src/features/sessionsV2/SessionSecretsModal.tsx +++ b/client/src/features/sessionsV2/SessionSecretsModal.tsx @@ -65,29 +65,37 @@ interface SessionSecretsModalProps { isOpen: boolean; project: Project; sessionSecretSlotsWithSecrets: SessionSecretSlotWithSecret[]; + onSkip?: () => void; + onCancel?: () => void; + title?: string; } export default function SessionSecretsModal({ isOpen, project, sessionSecretSlotsWithSecrets, + onSkip: onSkipProp, + onCancel: onCancelProp, + title = "Session secrets", }: SessionSecretsModalProps) { const { data: user } = useGetUserQueryState(); const navigate = useNavigate(); - const onCancel = useCallback(() => { + const defaultOnCancel = useCallback(() => { const url = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { namespace: project.namespace, slug: project.slug, }); navigate(url); }, [navigate, project.namespace, project.slug]); + const onCancel = onCancelProp ?? defaultOnCancel; const dispatch = useAppDispatch(); - const onSkip = useCallback(() => { + const defaultOnSkip = useCallback(() => { dispatch(startSessionOptionsV2Slice.actions.setUserSecretsReady(true)); }, [dispatch]); + const onSkip = onSkipProp ?? defaultOnSkip; const loginUrl = useLoginUrl(); const content = user?.isLoggedIn ? ( @@ -135,7 +143,7 @@ export default function SessionSecretsModal({ isOpen={isOpen} size="lg" > - Session secrets + {title} {content} - Modify session environment + Modify {launcherDefinition?.text.inline} environment } @@ -485,13 +569,14 @@ export function SessionView({ ({launcherResourceClass.max_storage} GB).

)} - {launcher && ( + {launcher && launcherCategory && ( )} @@ -504,8 +589,8 @@ export function SessionView({

- The default URL specifies the URL pathname on the session to go - to upon launch + The default URL specifies the URL pathname on the{" "} + {launcherDefinition?.text.inline} to go to upon launch

{launcher && launcher.environment?.default_url ? ( @@ -635,3 +720,107 @@ export function SessionView({ ); } + +interface JobListProps { + sessions: SessionV2[]; + project: Project; + openJobSubmissionId?: string; +} + +function JobListItem({ + session, + project, +}: { + session: SessionV2; + project: Project; +}) { + const { resourceRequests, isLoading } = useResourceClassDetails({ + resourceClassId: session.resource_class_id, + storage: session.resources?.requests?.storage, + }); + const sessionError = + session?.status?.state === "failed" ? session?.status?.message : undefined; + + return ( + + +

+ Job {session.submission_id} +

+ +
+ +
+ + +
+ + {sessionError && ( + + {sessionError} + + )} + + + {!isLoading && resourceRequests && ( + <> + + + + )} +
+
+ ); +} + +function JobList({ sessions, project, openJobSubmissionId }: JobListProps) { + const resolvedSubmissionId = useMemo( + () => resolveOpenJobSubmissionId(openJobSubmissionId, sessions), + [openJobSubmissionId, sessions], + ); + const defaultOpenJobs = useMemo( + () => + resolvedSubmissionId + ? [getJobAccordionTargetId(resolvedSubmissionId)] + : [], + [resolvedSubmissionId], + ); + + return ( +
+ {}} + > + {sessions.map((session) => ( + + ))} + +
+ ); +} diff --git a/client/src/features/sessionsV2/SessionsV2.tsx b/client/src/features/sessionsV2/SessionsV2.tsx index 7d7981d7ad..8b95176d4a 100644 --- a/client/src/features/sessionsV2/SessionsV2.tsx +++ b/client/src/features/sessionsV2/SessionsV2.tsx @@ -18,7 +18,7 @@ import cx from "classnames"; import { useCallback, useMemo } from "react"; -import { Pencil, PlayCircle, Trash } from "react-bootstrap-icons"; +import { Pencil, RocketTakeoff, Trash } from "react-bootstrap-icons"; import { generatePath } from "react-router"; import { Badge, @@ -109,8 +109,8 @@ export default function SessionsV2({ project }: SessionsV2Props) { <>

{totalSessions > 0 - ? "Session launchers are available to everyone who can see the project. Running sessions are only accessible to you." - : "Define interactive environments in which to do your work and share it with others."} + ? "Launchers are available to everyone who can see the project. Only you can see your running sessions and jobs." + : "Define interactive or not environments in which to do your work and share it with others."}

{loading} {totalSessions > 0 && !isLoading && ( @@ -145,8 +145,8 @@ export default function SessionsV2({ project }: SessionsV2Props) { >

- - Sessions + + Launchers

{totalSessions}
@@ -155,7 +155,7 @@ export default function SessionsV2({ project }: SessionsV2Props) { enabled={
diff --git a/client/src/features/sessionsV2/StartSessionButton.tsx b/client/src/features/sessionsV2/StartSessionButton.tsx deleted file mode 100644 index 7e24b34507..0000000000 --- a/client/src/features/sessionsV2/StartSessionButton.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/*! - * Copyright 2024 - 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 { skipToken } from "@reduxjs/toolkit/query/react"; -import cx from "classnames"; -import { ReactNode, useContext } from "react"; -import { PlayCircle } from "react-bootstrap-icons"; -import { generatePath, Link } from "react-router"; -import { UncontrolledTooltip } from "reactstrap"; - -import { useGetEnvironmentsByEnvironmentIdBuildsQuery as useGetBuildsQuery } from "~/features/sessionsV2/api/sessionLaunchersV2.api"; -import AppContext from "~/utils/context/appContext"; -import { DEFAULT_APP_PARAMS } from "~/utils/context/appParams.constants"; -import { ButtonWithMenuV2 } from "../../components/buttons/Button"; -import { ABSOLUTE_ROUTES } from "../../routing/routes.constants"; -import { SessionLauncher } from "./api/sessionLaunchersV2.generated-api"; -import { useGetSessionsImagesQuery } from "./api/sessionsV2.api"; -import { CUSTOM_LAUNCH_SEARCH_PARAM } from "./session.constants"; - -interface StartSessionButtonProps { - namespace: string; - slug: string; - launcher: SessionLauncher; - disabled?: boolean; - useOldImage?: boolean; - otherActions?: ReactNode; - isDisabledDropdownToggle?: boolean; -} - -export default function StartSessionButton({ - launcher, - namespace, - slug, -}: StartSessionButtonProps) { - const startUrl = generatePath( - ABSOLUTE_ROUTES.v2.projects.show.sessions.start, - { - launcherId: launcher.id, - namespace, - slug, - }, - ); - const environment = launcher?.environment; - const isExternalImageEnvironment = - environment?.environment_kind === "CUSTOM" && - environment?.environment_image_source === "image"; - const { data, isLoading } = useGetSessionsImagesQuery( - environment && - environment.environment_kind === "CUSTOM" && - environment.container_image - ? { imageUrl: environment.container_image } - : skipToken, - ); - const { params } = useContext(AppContext); - const imageBuildersEnabled = - params?.IMAGE_BUILDERS_ENABLED ?? DEFAULT_APP_PARAMS.IMAGE_BUILDERS_ENABLED; - const { data: builds } = useGetBuildsQuery( - imageBuildersEnabled && environment.environment_image_source === "build" - ? { environmentId: environment.id } - : skipToken, - ); - - const hasSuccessfulBuild = builds?.find( - (build) => build.status === "succeeded", - ); - - const force = isExternalImageEnvironment && !isLoading && !data?.accessible; - - const isLaunchButtonDisabled = - environment.environment_image_source === "build" && !hasSuccessfulBuild; - const launchButtonDisableReason = - "No image available. Run the Build action to generate an image."; - - const launchAction = ( - - - - {force ? "Force launch" : "Launch"} - - {isLaunchButtonDisabled && ( - - {launchButtonDisableReason} - - )} - - ); - - const customizeLaunch = ( - - - {force ? "Force custom launch" : "Custom launch"} - - ); - - return ( - <> - - {customizeLaunch} - - - ); -} diff --git a/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts b/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts index 25b44f4247..0ed1e84513 100644 --- a/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts +++ b/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts @@ -8,9 +8,7 @@ const injectedRtkApi = api.injectEndpoints({ >({ query: (queryArg) => ({ url: `/environments`, - params: { - get_environment_params: queryArg.getEnvironmentParams, - }, + params: { get_environment_params: queryArg.getEnvironmentParams }, }), }), postEnvironments: build.mutation< @@ -121,9 +119,7 @@ const injectedRtkApi = api.injectEndpoints({ >({ query: (queryArg) => ({ url: `/builds/${queryArg.buildId}/logs`, - params: { - max_lines: queryArg.maxLines, - }, + params: { max_lines: queryArg.maxLines }, }), }), getEnvironmentsByEnvironmentIdBuilds: build.query< @@ -171,7 +167,8 @@ export type PatchEnvironmentsByEnvironmentIdApiArg = { environmentId: Ulid; environmentPatch: EnvironmentPatch; }; -export type DeleteEnvironmentsByEnvironmentIdApiResponse = unknown; +export type DeleteEnvironmentsByEnvironmentIdApiResponse = + /** status 204 The session environment was removed or did not exist in the first place */ void; export type DeleteEnvironmentsByEnvironmentIdApiArg = { environmentId: Ulid; }; @@ -194,7 +191,8 @@ export type PatchSessionLaunchersByLauncherIdApiArg = { launcherId: Ulid; sessionLauncherPatch: SessionLauncherPatch; }; -export type DeleteSessionLaunchersByLauncherIdApiResponse = unknown; +export type DeleteSessionLaunchersByLauncherIdApiResponse = + /** status 204 The session was removed or did not exist in the first place */ void; export type DeleteSessionLaunchersByLauncherIdApiArg = { launcherId: Ulid; }; @@ -275,8 +273,12 @@ export type ErrorResponse = { trace_id?: string; }; }; +export type CommandAndArgs = { + command?: EnvironmentCommand; + args?: EnvironmentArgs; +}; export type EnvironmentImageSourceImage = "image"; -export type EnvironmentPost = { +export type EnvironmentPost = CommandAndArgs & { name: SessionName; description?: Description; container_image: ContainerImage; @@ -286,19 +288,15 @@ export type EnvironmentPost = { working_directory?: EnvironmentWorkingDirectory; mount_directory?: EnvironmentMountDirectory; port?: EnvironmentPort; - command?: EnvironmentCommand; - args?: EnvironmentArgs; is_archived?: IsArchived; environment_image_source: EnvironmentImageSourceImage; strip_path_prefix?: StripPathPrefix; }; export type EnvironmentWorkingDirectoryPatch = string; export type EnvironmentMountDirectoryPatch = string; -export type EnvironmentPatchCommand = string[] | null; -export type EnvironmentPatchArgs = string[] | null; export type IsArchivedPatch = boolean; export type StripPathPrefixPatch = boolean; -export type EnvironmentPatch = { +export type EnvironmentPatch = CommandAndArgs & { name?: SessionName; description?: Description; container_image?: ContainerImage; @@ -308,8 +306,6 @@ export type EnvironmentPatch = { working_directory?: EnvironmentWorkingDirectoryPatch; mount_directory?: EnvironmentMountDirectoryPatch; port?: EnvironmentPort; - command?: EnvironmentPatchCommand; - args?: EnvironmentPatchArgs; is_archived?: IsArchivedPatch; strip_path_prefix?: StripPathPrefixPatch; }; @@ -325,7 +321,7 @@ export type BuilderVariant = string; export type FrontendVariant = string; export type RepositoryRevision = string; export type BuildContextDir = string; -export type BuildParameters = { +export type BuildParameters = any & { repository: Repository; platforms?: BuildPlatforms; builder_variant: BuilderVariant; @@ -350,7 +346,7 @@ export type EnvVar = { value?: string; }; export type EnvVariables = EnvVar[]; -export type LauncherType = "interactive" | "non_interactive"; +export type LauncherType = "interactive" | "non-interactive"; export type SessionLauncher = { id: Ulid; project_id: Ulid; @@ -367,9 +363,10 @@ export type SessionLaunchersList = SessionLauncher[]; export type EnvironmentPostInLauncherHelper = EnvironmentPost & { environment_kind: EnvironmentKind; }; -export type BuildParametersPost = BuildParameters & { - environment_image_source: EnvironmentImageSourceBuild; -}; +export type BuildParametersPost = BuildParameters & + CommandAndArgs & { + environment_image_source: EnvironmentImageSourceBuild; + }; export type EnvironmentPostInLauncher = | EnvironmentPostInLauncherHelper | BuildParametersPost; diff --git a/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json b/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json index 39d098d68d..a2acf3abd2 100644 --- a/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json +++ b/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json @@ -724,82 +724,83 @@ }, "EnvironmentPost": { "description": "Data required to create a session environment", - "type": "object", - "properties": { - "name": { - "$ref": "#/components/schemas/SessionName" - }, - "description": { - "$ref": "#/components/schemas/Description" - }, - "container_image": { - "$ref": "#/components/schemas/ContainerImage" - }, - "default_url": { - "allOf": [ - { - "$ref": "#/components/schemas/DefaultUrl" - } - ], - "default": "/lab" - }, - "uid": { - "allOf": [ - { - "$ref": "#/components/schemas/EnvironmentUid" - } - ], - "default": 1000 - }, - "gid": { - "allOf": [ - { - "$ref": "#/components/schemas/EnvironmentGid" - } - ], - "default": 1000 - }, - "working_directory": { - "$ref": "#/components/schemas/EnvironmentWorkingDirectory" - }, - "mount_directory": { - "$ref": "#/components/schemas/EnvironmentMountDirectory" - }, - "port": { - "allOf": [ - { - "$ref": "#/components/schemas/EnvironmentPort" - } - ], - "default": 8080 - }, - "command": { - "$ref": "#/components/schemas/EnvironmentCommand" - }, - "args": { - "$ref": "#/components/schemas/EnvironmentArgs" - }, - "is_archived": { - "allOf": [ - { - "$ref": "#/components/schemas/IsArchived" - } - ], - "default": false - }, - "environment_image_source": { - "$ref": "#/components/schemas/EnvironmentImageSourceImage" + "allOf": [ + { + "$ref": "#/components/schemas/CommandAndArgs" }, - "strip_path_prefix": { - "allOf": [ - { - "$ref": "#/components/schemas/StripPathPrefix" + { + "type": "object", + "properties": { + "name": { + "$ref": "#/components/schemas/SessionName" + }, + "description": { + "$ref": "#/components/schemas/Description" + }, + "container_image": { + "$ref": "#/components/schemas/ContainerImage" + }, + "default_url": { + "allOf": [ + { + "$ref": "#/components/schemas/DefaultUrl" + } + ], + "default": "/lab" + }, + "uid": { + "allOf": [ + { + "$ref": "#/components/schemas/EnvironmentUid" + } + ], + "default": 1000 + }, + "gid": { + "allOf": [ + { + "$ref": "#/components/schemas/EnvironmentGid" + } + ], + "default": 1000 + }, + "working_directory": { + "$ref": "#/components/schemas/EnvironmentWorkingDirectory" + }, + "mount_directory": { + "$ref": "#/components/schemas/EnvironmentMountDirectory" + }, + "port": { + "allOf": [ + { + "$ref": "#/components/schemas/EnvironmentPort" + } + ], + "default": 8080 + }, + "is_archived": { + "allOf": [ + { + "$ref": "#/components/schemas/IsArchived" + } + ], + "default": false + }, + "environment_image_source": { + "$ref": "#/components/schemas/EnvironmentImageSourceImage" + }, + "strip_path_prefix": { + "allOf": [ + { + "$ref": "#/components/schemas/StripPathPrefix" + } + ], + "default": false } - ], - "default": false + }, + "required": ["name", "container_image", "environment_image_source"] } - }, - "required": ["name", "container_image", "environment_image_source"] + ] }, "EnvironmentPatchInLauncher": { "allOf": [ @@ -823,54 +824,55 @@ ] }, "EnvironmentPatch": { - "type": "object", - "description": "Update a session environment", - "additionalProperties": false, - "properties": { - "name": { - "$ref": "#/components/schemas/SessionName" - }, - "description": { - "$ref": "#/components/schemas/Description" - }, - "container_image": { - "$ref": "#/components/schemas/ContainerImage" - }, - "default_url": { - "$ref": "#/components/schemas/DefaultUrl" - }, - "uid": { - "$ref": "#/components/schemas/EnvironmentUid" - }, - "gid": { - "$ref": "#/components/schemas/EnvironmentGid" - }, - "working_directory": { - "$ref": "#/components/schemas/EnvironmentWorkingDirectoryPatch" - }, - "mount_directory": { - "$ref": "#/components/schemas/EnvironmentMountDirectoryPatch" - }, - "port": { - "$ref": "#/components/schemas/EnvironmentPort" - }, - "command": { - "$ref": "#/components/schemas/EnvironmentPatchCommand" - }, - "args": { - "$ref": "#/components/schemas/EnvironmentPatchArgs" - }, - "is_archived": { - "$ref": "#/components/schemas/IsArchivedPatch" + "allOf": [ + { + "$ref": "#/components/schemas/CommandAndArgs" }, - "strip_path_prefix": { - "$ref": "#/components/schemas/StripPathPrefixPatch" + { + "type": "object", + "description": "Update a session environment", + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/components/schemas/SessionName" + }, + "description": { + "$ref": "#/components/schemas/Description" + }, + "container_image": { + "$ref": "#/components/schemas/ContainerImage" + }, + "default_url": { + "$ref": "#/components/schemas/DefaultUrl" + }, + "uid": { + "$ref": "#/components/schemas/EnvironmentUid" + }, + "gid": { + "$ref": "#/components/schemas/EnvironmentGid" + }, + "working_directory": { + "$ref": "#/components/schemas/EnvironmentWorkingDirectoryPatch" + }, + "mount_directory": { + "$ref": "#/components/schemas/EnvironmentMountDirectoryPatch" + }, + "port": { + "$ref": "#/components/schemas/EnvironmentPort" + }, + "is_archived": { + "$ref": "#/components/schemas/IsArchivedPatch" + }, + "strip_path_prefix": { + "$ref": "#/components/schemas/StripPathPrefixPatch" + } + } } - } + ] }, "LauncherType": { "type": "string", - "enum": ["interactive", "non_interactive"] + "enum": ["interactive", "non-interactive"] }, "SessionLaunchersList": { "description": "A list of Renku session launchers", @@ -974,7 +976,7 @@ }, "SessionLauncherPatch": { "type": "object", - "description": "Update a session launcher", + "description": "Update a session launcher. For non-interactive launchers, a `command` is required.", "additionalProperties": false, "properties": { "name": { @@ -1124,35 +1126,45 @@ }, "BuildParameters": { "description": "Build parameters", - "type": "object", - "additionalProperties": false, - "properties": { - "repository": { - "$ref": "#/components/schemas/Repository" - }, - "platforms": { - "$ref": "#/components/schemas/BuildPlatforms" - }, - "builder_variant": { - "$ref": "#/components/schemas/BuilderVariant" - }, - "frontend_variant": { - "$ref": "#/components/schemas/FrontendVariant" - }, - "repository_revision": { - "$ref": "#/components/schemas/RepositoryRevision" + "allOf": [ + { + "ref": "#/components/schemas/CommandAndArgs" }, - "context_dir": { - "$ref": "#/components/schemas/BuildContextDir" + { + "type": "object", + "additionalProperties": false, + "properties": { + "repository": { + "$ref": "#/components/schemas/Repository" + }, + "platforms": { + "$ref": "#/components/schemas/BuildPlatforms" + }, + "builder_variant": { + "$ref": "#/components/schemas/BuilderVariant" + }, + "frontend_variant": { + "$ref": "#/components/schemas/FrontendVariant" + }, + "repository_revision": { + "$ref": "#/components/schemas/RepositoryRevision" + }, + "context_dir": { + "$ref": "#/components/schemas/BuildContextDir" + } + }, + "required": ["repository", "builder_variant", "frontend_variant"] } - }, - "required": ["repository", "builder_variant", "frontend_variant"] + ] }, "BuildParametersPost": { "allOf": [ { "$ref": "#/components/schemas/BuildParameters" }, + { + "$ref": "#/components/schemas/CommandAndArgs" + }, { "type": "object", "properties": { @@ -1188,6 +1200,18 @@ } } }, + "CommandAndArgs": { + "description": "The command and arguments.", + "type": "object", + "properties": { + "command": { + "$ref": "#/components/schemas/EnvironmentCommand" + }, + "args": { + "$ref": "#/components/schemas/EnvironmentArgs" + } + } + }, "ContainerImage": { "description": "A container image", "type": "string", diff --git a/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts b/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts index b021e16c95..f378c90814 100644 --- a/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts +++ b/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts @@ -12,9 +12,7 @@ const injectedRtkApi = api.injectEndpoints({ getSessions: build.query({ query: (queryArg) => ({ url: `/sessions`, - params: { - session_type: queryArg.sessionType, - }, + params: { session_type: queryArg.sessionType }, }), }), getSessionsBySessionId: build.query< @@ -48,9 +46,7 @@ const injectedRtkApi = api.injectEndpoints({ >({ query: (queryArg) => ({ url: `/sessions/${queryArg.sessionId}/logs`, - params: { - max_lines: queryArg.maxLines, - }, + params: { max_lines: queryArg.maxLines }, }), }), getSessionsImages: build.query< @@ -59,9 +55,7 @@ const injectedRtkApi = api.injectEndpoints({ >({ query: (queryArg) => ({ url: `/sessions/images`, - params: { - image_url: queryArg.imageUrl, - }, + params: { image_url: queryArg.imageUrl }, }), }), }), @@ -86,7 +80,8 @@ export type GetSessionsBySessionIdApiArg = { /** The id of the session */ sessionId: string; }; -export type DeleteSessionsBySessionIdApiResponse = unknown; +export type DeleteSessionsBySessionIdApiResponse = + /** status 204 The session was deleted or it never existed in the first place */ void; export type DeleteSessionsBySessionIdApiArg = { /** The id of the session that should be deleted */ sessionId: string; @@ -141,17 +136,23 @@ export type SessionStatus = { total_containers: number; }; export type Ulid = string; +export type SessionType = "interactive" | "non-interactive"; +export type SubmissionId = string; export type SessionResponse = { image: string; name: ServerName; resources: SessionResources; started: string | null; + job_completed_at?: string | null; lastInteraction?: string | null; status: SessionStatus; url: string; project_id: Ulid; launcher_id: Ulid; resource_class_id: number; + session_type: SessionType; + submission_id?: SubmissionId; + command_args?: string[] | null; }; export type ErrorResponse = { error: { @@ -188,11 +189,13 @@ export type SessionPostRequest = { /** The size of disk storage for the session, in gigabytes */ disk_storage?: number; resource_class_id?: number | null; + submission_id?: SubmissionId; data_connectors_overrides?: SessionDataConnectorsOverrideList; env_variable_overrides?: EnvVariableOverrides; + job_command_override?: string[] | null; + job_args_override?: string[] | null; }; export type SessionListResponse = SessionResponse[]; -export type SessionType = "interactive" | "non-interactive"; export type CurrentTime = "now"; export type SessionPatchRequest = { resource_class_id?: number; diff --git a/client/src/features/sessionsV2/api/sessionsV2.openapi.json b/client/src/features/sessionsV2/api/sessionsV2.openapi.json index 8dc9bde5cd..fe04e30b83 100644 --- a/client/src/features/sessionsV2/api/sessionsV2.openapi.json +++ b/client/src/features/sessionsV2/api/sessionsV2.openapi.json @@ -335,11 +335,28 @@ "nullable": true, "type": "integer" }, + "submission_id": { + "$ref": "#/components/schemas/SubmissionId" + }, "data_connectors_overrides": { "$ref": "#/components/schemas/SessionDataConnectorsOverrideList" }, "env_variable_overrides": { "$ref": "#/components/schemas/EnvVariableOverrides" + }, + "job_command_override": { + "nullable": true, + "type": "array", + "items": { + "type": "string" + } + }, + "job_args_override": { + "nullable": true, + "type": "array", + "items": { + "type": "string" + } } }, "required": ["launcher_id"], @@ -361,6 +378,11 @@ "nullable": true, "type": "string" }, + "job_completed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, "lastInteraction": { "type": "string", "format": "date-time", @@ -380,6 +402,20 @@ }, "resource_class_id": { "type": "integer" + }, + "session_type": { + "$ref": "#/components/schemas/SessionType" + }, + "submission_id": { + "$ref": "#/components/schemas/SubmissionId", + "nullable": true + }, + "command_args": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true } }, "required": [ @@ -391,7 +427,8 @@ "url", "project_id", "launcher_id", - "resource_class_id" + "resource_class_id", + "session_type" ], "type": "object" }, @@ -599,6 +636,11 @@ "pattern": "^[a-z]([-a-z0-9]*[a-z0-9])?$", "example": "d185e68d-d43-renku-2-b9ac279a4e8a85ac28d08" }, + "SubmissionId": { + "type": "string", + "pattern": "^[a-z][-0-9a-z]{3,19}$", + "description": "When submitting a job, i.e. the launcher used is a job\nlauncher, the submission id is required to deduplicate\nsame job submissions and allows retries.\n" + }, "ImageCheckResponse": { "type": "object", "properties": { diff --git a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.actions.tsx b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.actions.tsx new file mode 100644 index 0000000000..b0a8a21c43 --- /dev/null +++ b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.actions.tsx @@ -0,0 +1,295 @@ +/*! + * 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 cx from "classnames"; +import { ReactNode } from "react"; +import { + ArrowRightCircle, + FileEarmarkText, + PauseCircle, + PlayFill, + Tools, + Trash, +} from "react-bootstrap-icons"; +import { Link } from "react-router"; +import { Button } from "reactstrap"; + +import { Loader } from "~/components/Loader"; +import { SessionStatusState } from "../../sessionsV2.types"; + +export interface ActiveSessionActionContext { + status: SessionStatusState; + isStopping: boolean; + isHibernating: boolean; + isResuming: boolean; + failedScheduling: boolean; + isUserLoggedIn: boolean; + showSessionUrl: string; + buttonClassName: string; + onHibernateSession: () => void; + onStopSession: () => void; + onResumeSession: () => void; + toggleLogsModal: () => void; + toggleModifySession: () => void; +} + +function StoppingStatusButton({ label }: { label: string }) { + return ( + + ); +} + +function DismissJobButton({ + buttonClassName, + color = "outline-primary", + onStopSession, +}: { + buttonClassName?: string; + color?: "outline-primary" | "primary"; + onStopSession: () => void; +}) { + return ( + + ); +} + +function PausingStatusButton() { + return ( + + ); +} + +function ResumeStatusButton({ + isResuming, + onResumeSession, +}: { + isResuming: boolean; + onResumeSession: () => void; +}) { + return ( + + ); +} + +function LogsStatusButton({ + onClick, + label, + color = "outline-primary", +}: { + onClick: () => void; + label: string; + color?: "outline-primary" | "primary"; +}) { + return ( + + ); +} + +function OpenSessionButton({ showSessionUrl }: { showSessionUrl: string }) { + return ( + + + Open + + ); +} + +function PauseOrDeleteButton({ + buttonClassName, + color = "outline-primary", + isUserLoggedIn, + onHibernateSession, + onStopSession, +}: { + buttonClassName?: string; + color?: "outline-primary" | "primary"; + isUserLoggedIn: boolean; + onHibernateSession: () => void; + onStopSession: () => void; +}) { + return ( + + ); +} + +export function getInteractiveSessionDefaultAction( + ctx: ActiveSessionActionContext, +): ReactNode { + const { + status, + isStopping, + isHibernating, + isResuming, + failedScheduling, + isUserLoggedIn, + showSessionUrl, + buttonClassName, + onHibernateSession, + onStopSession, + onResumeSession, + toggleLogsModal, + toggleModifySession, + } = ctx; + + if (status === "stopping" || isStopping) { + return ; + } + if (isHibernating) { + return ; + } + if (status === "starting") { + return ; + } + if (status === "running") { + return ( + <> + + + + ); + } + if (status === "hibernated") { + return ( + + ); + } + if (failedScheduling) { + return ( + <> + + + + ); + } + return ( + <> + + + + ); +} + +export function getJobDefaultAction( + ctx: ActiveSessionActionContext, +): ReactNode { + const { + status, + isStopping, + isHibernating, + isResuming, + onResumeSession, + toggleLogsModal, + onStopSession, + } = ctx; + + if (status === "stopping" || isStopping) { + return ; + } + if (isHibernating) { + return ; + } + if (status === "starting" || status === "running") { + return ; + } + if (status === "succeeded") { + return ; + } + if (status === "hibernated") { + return ( + + ); + } + return ; +} diff --git a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx index bf5c4f4566..6640ecdd52 100644 --- a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx +++ b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx @@ -19,7 +19,6 @@ import cx from "classnames"; import { useCallback, useEffect, useState } from "react"; import { - ArrowRightCircle, BoxArrowUpRight, CheckLg, FileEarmarkText, @@ -29,7 +28,7 @@ import { Trash, XLg, } from "react-bootstrap-icons"; -import { Link, useNavigate } from "react-router"; +import { useNavigate } from "react-router"; import { SingleValue } from "react-select"; import { Button, @@ -44,9 +43,9 @@ import { import { WarnAlert } from "~/components/Alert"; import { ButtonWithMenuV2 } from "~/components/buttons/Button"; -import { Loader } from "~/components/Loader"; import useRenkuToast from "~/components/toast/useRenkuToast"; import SessionLogsModal from "~/features/logsDisplay/SessionLogsModal"; +import StopJobContent from "~/features/sessionsV2/components/SessionModals/StopJobContent"; import { useGetUserQueryState } from "~/features/usersV2/api/users.api"; import { NOTIFICATION_TOPICS } from "~/notifications/Notifications.constants"; import { @@ -58,6 +57,11 @@ import { useDeleteSessionsBySessionIdMutation as useStopSessionMutation, } from "../../api/sessionsV2.api"; import { + getLauncherCategoryDefinition, + sessionLauncherKindToCategory, +} from "../../session.utils"; +import { + LauncherCategory, SessionResources, SessionStatus, SessionStatusState, @@ -71,6 +75,10 @@ import { } from "../SessionModals/ResourceClassWarning"; import ShutdownSessionContent from "../SessionModals/ShoutdownSessionContent"; import { SessionRowResourceRequests } from "../SessionsList"; +import { + getInteractiveSessionDefaultAction, + getJobDefaultAction, +} from "./ActiveSessionButton.actions"; interface ActiveSessionButtonProps { className?: string; @@ -121,10 +129,7 @@ export default function ActiveSessionButton({ // TODO: fix react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect setIsResuming(false); - navigate(showSessionUrl); - // TODO: fix react-hooks/set-state-in-effect - - setIsResuming(false); + if (session.session_type === "interactive") navigate(showSessionUrl); } }, [ isResuming, @@ -132,6 +137,7 @@ export default function ActiveSessionButton({ isWaitingForResumedSession, navigate, showSessionUrl, + session.session_type, ]); useEffect(() => { if (errorResumeSession) { @@ -266,121 +272,32 @@ export default function ActiveSessionButton({ "btn-outline-primary", ); + const launcherCategory = sessionLauncherKindToCategory(session.session_type); + + const actionContext = { + status, + isStopping, + isHibernating, + isResuming, + failedScheduling, + isUserLoggedIn, + showSessionUrl, + buttonClassName, + onHibernateSession, + onStopSession, + onResumeSession, + toggleLogsModal, + toggleModifySession, + }; + const defaultAction = - status === "stopping" || isStopping ? ( - - ) : isHibernating ? ( - - ) : status === "starting" ? ( - - - Open - - ) : status === "running" ? ( - <> - - - - Open - - - ) : status === "hibernated" ? ( - - ) : failedScheduling ? ( - <> - - - - ) : ( - <> - - - - ); + launcherCategory === "session" + ? getInteractiveSessionDefaultAction(actionContext) + : launcherCategory === "job" + ? getJobDefaultAction(actionContext) + : null; + + const isRunning = status === "running" || status === "starting"; const hibernateAction = status !== "stopping" && (status !== "failed" || failedScheduling) && @@ -407,6 +324,16 @@ export default function ActiveSessionButton({ ); + const dismissAction = launcherCategory === "job" && ( + + + Dismiss + + ); + const modifyAction = (status === "hibernated" || status === "failed") && !isStopping && !isHibernating && @@ -439,18 +366,28 @@ export default function ActiveSessionButton({
- {deleteAction} - {modifyAction} - {(hibernateAction || deleteAction || modifyAction) && - (openInNewTabAction || logsAction) && } + {launcherCategory === "job" ? ( + status === "succeeded" ? ( + logsAction + ) : ( + dismissAction + ) + ) : ( + <> + {deleteAction} + {modifyAction} + {(hibernateAction || deleteAction || modifyAction) && + (openInNewTabAction || logsAction) && } - {openInNewTabAction} - {logsAction} + {openInNewTabAction} + {logsAction} + + )} - + {launcherCategory === "session" && ( + + )} void; + launcherCategory: LauncherCategory; } function ConfirmDeleteModal({ isOpen, @@ -495,23 +436,30 @@ function ConfirmDeleteModal({ onStopSession, sessionLauncherId, sessionProjectId, + status, toggleModal, + launcherCategory, }: ConfirmDeleteModalProps) { const onClick = useCallback(() => { onStopSession(); toggleModal(); }, [onStopSession, toggleModal]); + const launcherDefinition = getLauncherCategoryDefinition(launcherCategory); + return ( - - Shut Down Session + + {launcherDefinition.text.delete.title} - + {launcherCategory === "session" && ( + + )} + {launcherCategory === "job" && } diff --git a/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx b/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx index 60a5c269fe..bd7c452616 100644 --- a/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx +++ b/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx @@ -35,9 +35,16 @@ import { SessionEnvironmentForm } from "../../../admin/SessionEnvironmentFormCon import { DEFAULT_URL, ENVIRONMENT_VALUES_DESCRIPTION, + JOB_COMMAND_VALIDATION_MESSAGE, } from "../../session.constants"; -import { isValidJSONStringArray } from "../../session.utils"; -import { SessionLauncherForm } from "../../sessionsV2.types"; +import { + isValidJSONStringArray, + isValidRequiredJSONStringArray, +} from "../../session.utils"; +import { + SessionLauncherForm, + type LauncherCategory, +} from "../../sessionsV2.types"; function OptionalLabel() { return (Optional); @@ -48,6 +55,7 @@ interface FormFieldLabelProps { isOptional?: boolean; label: ReactNode; name: Path; + id?: string; } function FormFieldLabel({ @@ -85,6 +93,7 @@ function FormField({ rules, type = "text", isOptional, + id, }: FormFieldLabelProps & { control: Control; errors?: FieldErrors; @@ -104,6 +113,7 @@ function FormField({ rules={rules} type={type} isOptional={isOptional} + id={id} /> ); } @@ -124,7 +134,7 @@ function FormField({ ({ rules, type = "text", isOptional, + id, }: { control: Control; errors?: FieldErrors; @@ -163,6 +174,7 @@ function CheckboxOrRadioFormField({ rules?: ControllerProps["rules"]; type: InputType; isOptional?: boolean; + id?: string; }) { return (
@@ -174,7 +186,7 @@ function CheckboxOrRadioFormField({ ({ ); } -interface JsonFieldProps { +export interface JsonFieldProps { control: Control; name: Path; label: string; @@ -214,17 +226,36 @@ interface JsonFieldProps { errors?: FieldErrors; helpText: string; isOptional?: boolean; + dataCy?: string; + id?: string; + disabled?: boolean; } -function JsonField({ +export function JsonField({ control, name, label, info, errors, helpText, - isOptional, + isOptional = true, + dataCy, + id, + disabled = false, }: JsonFieldProps) { + const rules = isOptional + ? { + validate: (value: string) => isValidJSONStringArray(value?.toString()), + } + : { + validate: (value: string) => + isValidRequiredJSONStringArray( + value?.toString(), + JOB_COMMAND_VALIDATION_MESSAGE.required, + JOB_COMMAND_VALIDATION_MESSAGE.empty, + ), + }; + return ( <> ({ isValidJSONStringArray(value?.toString()), + rules={rules} + render={({ field, fieldState: { error: fieldError } }) => { + const error = errors?.[name] ?? fieldError; + return ( + <> +