From 3d949974e09e659aa9a0ea4baa50bbed68540db2 Mon Sep 17 00:00:00 2001 From: Andrea Cordoba Date: Thu, 4 Jun 2026 15:16:17 +0200 Subject: [PATCH 01/11] feat: add job launcher --- .../src/components/entities/entities.types.ts | 1 + .../offcanvas/OffcanvasHeaderWithType.tsx | 15 +- .../SessionEnvironmentAdvancedFields.tsx | 7 +- .../sessionsV2/AddSessionLauncherButton.tsx | 8 +- .../sessionsV2/DeleteSessionLauncherModal.tsx | 37 ++- .../SessionList/SessionLauncherCard.tsx | 207 ++++++++------ .../SessionList/SessionLauncherDisplay.tsx | 10 +- .../SessionView/EnvironmentItem.tsx | 116 ++++---- .../sessionsV2/SessionView/SessionView.tsx | 103 ++++--- client/src/features/sessionsV2/SessionsV2.tsx | 20 +- .../sessionsV2/StartSessionButton.tsx | 52 ++-- .../api/sessionLaunchersV2.generated-api.ts | 20 +- .../api/sessionLaunchersV2.openapi.json | 14 +- .../api/sessionsV2.generated-api.ts | 25 +- .../sessionsV2/api/sessionsV2.openapi.json | 44 ++- .../SessionForm/AdvancedSettingsFields.tsx | 256 ++++++++++-------- .../SessionForm/BuilderEnvironmentFields.tsx | 67 ++++- .../SessionForm/CustomEnvironmentFields.tsx | 48 ++-- .../SessionForm/EditLauncherFormContent.tsx | 72 +++-- .../SessionForm/EnvironmentField.tsx | 22 +- .../SessionForm/EnvironmentKindField.tsx | 218 ++++++++------- .../SessionForm/LauncherCategoryIcon.tsx | 129 +++++++++ .../SessionForm/LauncherDetailsFields.tsx | 21 +- .../SessionForm/LauncherEnvironmentIcon.tsx | 7 +- .../components/SessionLauncherButtons.tsx | 170 +++++++----- .../SessionModals/ModifyResourcesLauncher.tsx | 29 +- ...erModal.tsx => NewLauncherCreateModal.tsx} | 211 ++++++++++----- .../NewLauncherModal.module.scss | 10 + .../SessionModals/NewLauncherModal.tsx | 159 +++++++++++ .../UpdateSessionLauncherMetadataModal.tsx | 39 ++- .../UpdateSessionLauncherModal.tsx | 36 ++- .../components/SubmitJobLauncherAction.tsx | 52 ++++ .../features/sessionsV2/session.constants.tsx | 45 ++- .../src/features/sessionsV2/session.utils.ts | 221 ++++++++++++--- .../features/sessionsV2/sessionsV2.types.ts | 43 ++- tests/cypress/e2e/projectV2.spec.ts | 4 +- tests/cypress/e2e/projectV2setup.spec.ts | 29 +- .../projectV2/session-launchers-global.json | 3 +- .../projectV2/session-launchers-job.json | 24 ++ ...ession-launchers-with-renkulab-gitlab.json | 6 +- .../session-launchers-without-env-vars.json | 3 +- .../fixtures/projectV2/session-launchers.json | 3 +- tests/cypress/support/commands/sessions.ts | 67 +++++ 43 files changed, 1938 insertions(+), 735 deletions(-) create mode 100644 client/src/features/sessionsV2/components/SessionForm/LauncherCategoryIcon.tsx rename client/src/features/sessionsV2/components/SessionModals/{NewSessionLauncherModal.tsx => NewLauncherCreateModal.tsx} (61%) create mode 100644 client/src/features/sessionsV2/components/SessionModals/NewLauncherModal.module.scss create mode 100644 client/src/features/sessionsV2/components/SessionModals/NewLauncherModal.tsx create mode 100644 client/src/features/sessionsV2/components/SubmitJobLauncherAction.tsx create mode 100644 tests/cypress/fixtures/projectV2/session-launchers-job.json create mode 100644 tests/cypress/support/commands/sessions.ts 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..835b7cb27a 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" ? ( ) : ( @@ -38,12 +41,12 @@ export default function OffcanvasHeaderWithType({ const entityName = _entityName ? _entityName : entityType === "data-connector" - ? "Data connector" - : entityType === "session-launcher" - ? "Session launcher" - : entityType === "code-repository" - ? "Code repository" - : "Unknown"; + ? "Data connector" + : entityType === "session-launcher" + ? "Session launcher" + : entityType === "code-repository" + ? "Code repository" + : "Unknown"; return (
diff --git a/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx b/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx index d56bc897d8..03ce3abe41 100644 --- a/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx +++ b/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx @@ -39,9 +39,9 @@ export default function SessionEnvironmentAdvancedFields({ const toggleIsOpen = useCallback( () => setIsAdvancedSettingsOpen( - (isAdvancedSettingOpen) => !isAdvancedSettingOpen, + (isAdvancedSettingOpen) => !isAdvancedSettingOpen ), - [], + [] ); return ( <> @@ -56,7 +56,7 @@ export default function SessionEnvironmentAdvancedFields({ "fw-bold", "gap-1", "p-0", - "w-100", + "w-100" )} type="button" onClick={toggleIsOpen} @@ -70,6 +70,7 @@ export default function SessionEnvironmentAdvancedFields({ control={control} errors={errors} + launcherCategory="session" /> 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/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/SessionList/SessionLauncherCard.tsx b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx index 42587c0165..01c8ac21c2 100644 --- a/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx @@ -19,7 +19,13 @@ import { skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; import { useContext, useMemo } from "react"; -import { CircleFill, Link45deg, Pencil, Trash } from "react-bootstrap-icons"; +import { + CircleFill, + Link45deg, + Pencil, + PlayCircle, + Trash, +} from "react-bootstrap-icons"; import { Card, CardBody, Col, DropdownItem, Row } from "reactstrap"; import SessionEnvironmentGitLabWarningBadge from "~/features/legacy/SessionEnvironmentGitLabWarnBadge"; @@ -47,6 +53,10 @@ import { import { SessionLauncherButtons } from "../components/SessionLauncherButtons"; import SessionImageBadge from "../components/SessionStatus/SessionImageBadge"; import { SessionBadge } from "../components/SessionStatus/SessionStatus"; +import { + getLauncherCategory, + getLauncherCategoryDefinitionByLauncher, +} from "../session.utils"; import { SessionV2 } from "../sessionsV2.types"; import SessionCard from "./SessionCard"; @@ -86,14 +96,18 @@ export default function SessionLauncherCard({ const { data: builds, isLoading } = useGetBuildsQuery( imageBuildersEnabled && isCodeEnvironment ? { environmentId: environment.id } - : skipToken, + : skipToken ); const lastBuild = builds?.at(0); const lastSuccessfulBuild = builds?.find( - (build) => build.status === "succeeded" && build.id !== lastBuild?.id, + (build) => build.status === "succeeded" && build.id !== lastBuild?.id ); const hasSession = !!sessions?.length; + const launcherDefinition = + launcher && getLauncherCategoryDefinitionByLauncher(launcher); + const LauncherTypeIcon = launcherDefinition?.icon || PlayCircle; + const launcherTypeLabel = launcherDefinition?.text.display || null; sessionLaunchersV2Api.endpoints.getEnvironmentsByEnvironmentIdBuilds.useQuerySubscription( isCodeEnvironment && lastBuild?.status === "in_progress" @@ -101,15 +115,15 @@ export default function SessionLauncherCard({ : skipToken, { pollingInterval: 1_000, - }, + } ); const otherLauncherActions = launcher && - toggleUpdate && - toggleDelete && - toggleShareLink && - toggleUpdateEnvironment && ( - - classes.some(({ id }) => id === launcher.resource_class_id), + classes.some(({ id }) => id === launcher.resource_class_id) ); }, [launcher?.resource_class_id, resourcePools]); @@ -154,7 +168,7 @@ export default function SessionLauncherCard({ styles.SessionLauncherCard, "cursor-pointer", "shadow-none", - "rounded-0", + "rounded-0" )} data-cy="session-launcher-item" onClick={toggleSessionView} @@ -164,35 +178,52 @@ export default function SessionLauncherCard({
- - - - 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} + + + )} @@ -292,7 +323,7 @@ export default function SessionLauncherCard({ "d-flex", "flex-column", "align-items-end", - "gap-2", + "gap-2" )} > 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..e48b7ba62e 100644 --- a/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx @@ -60,7 +60,7 @@ export function SessionLauncherDisplay({ const launcherHash = useMemo(() => `launcher-${launcher.id}`, [launcher.id]); const isSessionViewOpen = useMemo( () => hash === launcherHash, - [hash, launcherHash], + [hash, launcherHash] ); const toggleSessionView = useCallback(() => { setHash((prev) => { @@ -77,10 +77,10 @@ export function SessionLauncherDisplay({ ? sessions.filter( (session) => session.launcher_id === launcher.id && - session.project_id === project.id, + session.project_id === project.id ) : [], - [launcher.id, project.id, sessions], + [launcher.id, project.id, sessions] ); return ( @@ -94,7 +94,9 @@ export function SessionLauncherDisplay({ toggleUpdate={toggleUpdate} toggleUpdateEnvironment={toggleUpdateEnvironment} toggleDelete={toggleDelete} - toggleShareLink={toggleShareLink} + toggleShareLink={ + launcher.launcher_type === "interactive" ? toggleShareLink : undefined + } toggleSessionView={toggleSessionView} />

@@ -159,7 +159,7 @@ function GlobalEnvironmentSessionImageBadge({ const { data, isLoading } = useGetSessionsImagesQuery( environment && environment.container_image ? { imageUrl: environment.container_image } - : skipToken, + : skipToken ); const { data: resourcePools, isLoading: isLoadingResourcePools } = computeResourcesApi.endpoints.getResourcePools.useQueryState({}); @@ -168,7 +168,7 @@ function GlobalEnvironmentSessionImageBadge({ return undefined; } return resourcePools.find(({ classes }) => - classes.some(({ id }) => id === launcher.resource_class_id), + classes.some(({ id }) => id === launcher.resource_class_id) ); }, [launcher.resource_class_id, resourcePools]); @@ -203,6 +203,7 @@ function CustomImageEnvironmentValues({ }) { const { pathname, hash } = useLocation(); const environment = launcher.environment; + const launcherCategory = getLauncherCategory(launcher); const { params } = useContext(AppContext); const renkuContactEmail = @@ -213,7 +214,7 @@ function CustomImageEnvironmentValues({ environment.environment_kind === "CUSTOM" && environment.container_image ? { imageUrl: environment.container_image } - : skipToken, + : skipToken ); const { data: resourcePools, isLoading: isLoadingResourcePools } = computeResourcesApi.endpoints.getResourcePools.useQueryState({}); @@ -222,7 +223,7 @@ function CustomImageEnvironmentValues({ return undefined; } return resourcePools.find(({ classes }) => - classes.some(({ id }) => id === launcher.resource_class_id), + classes.some(({ id }) => id === launcher.resource_class_id) ); }, [launcher.resource_class_id, resourcePools]); const search = useMemo(() => { @@ -328,36 +329,40 @@ function CustomImageEnvironmentValues({ label="Container image" value={environment?.container_image || ""} /> - - - - - - + {launcherCategory === "session" && ( + <> + + + + + + + + )} - + {launcherCategory === "session" && ( + + )} ); } @@ -394,12 +401,12 @@ function CustomBuildEnvironmentValues({ } = useGetBuildsQuery( imageBuildersEnabled && environment.environment_image_source === "build" ? { environmentId: environment.id } - : skipToken, + : skipToken ); const lastBuild = builds?.at(0); const lastSuccessfulBuild = builds?.find( - (build) => build.status === "succeeded" && build.id !== lastBuild?.id, + (build) => build.status === "succeeded" && build.id !== lastBuild?.id ); sessionLaunchersV2Api.endpoints.getEnvironmentsByEnvironmentIdBuilds.useQuerySubscription( @@ -408,14 +415,14 @@ function CustomBuildEnvironmentValues({ : skipToken, { pollingInterval: 1_000, - }, + } ); const { data: imageCheck, isLoading: isLoadingContainerImage } = useGetSessionsImagesQuery( environment.container_image != null ? { imageUrl: environment.container_image } - : skipToken, + : skipToken ); const { data: resourcePools, isLoading: isLoadingResourcePools } = computeResourcesApi.endpoints.getResourcePools.useQueryState({}); @@ -424,7 +431,7 @@ function CustomBuildEnvironmentValues({ return undefined; } return resourcePools.find(({ classes }) => - classes.some(({ id }) => id === launcher.resource_class_id), + classes.some(({ id }) => id === launcher.resource_class_id) ); }, [launcher.resource_class_id, resourcePools]); @@ -444,6 +451,7 @@ function CustomBuildEnvironmentValues({ return null; } + const launcherCategory = getLauncherCategory(launcher); const { build_parameters } = environment; const { builder_variant, @@ -451,6 +459,8 @@ function CustomBuildEnvironmentValues({ frontend_variant, repository_revision, repository, + job_command, + job_args, } = build_parameters; return ( @@ -547,6 +557,20 @@ function CustomBuildEnvironmentValues({ label="User interface" value={frontend_variant || ""} /> + {launcherCategory === "job" && ( + <> + + + + )} {environment.container_image !== BUILDER_IMAGE_NOT_READY_VALUE && ( @@ -617,7 +641,7 @@ function NotReadyStatusBadge() { "border-danger", "text-danger-emphasis", "fs-small", - "fw-normal", + "fw-normal" )} pill > diff --git a/client/src/features/sessionsV2/SessionView/SessionView.tsx b/client/src/features/sessionsV2/SessionView/SessionView.tsx index cbffbe5a84..ff2a168397 100644 --- a/client/src/features/sessionsV2/SessionView/SessionView.tsx +++ b/client/src/features/sessionsV2/SessionView/SessionView.tsx @@ -74,8 +74,12 @@ import { SessionStatusV2Title, } from "../components/SessionStatus/SessionStatus"; import { DEFAULT_URL } from "../session.constants"; +import { + getLauncherCategory, + getLauncherCategoryDefinition, +} from "../session.utils"; import { getShowSessionUrlByProject, SessionV2Actions } from "../SessionsV2"; -import { SessionV2 } from "../sessionsV2.types"; +import { LauncherCategory, SessionV2 } from "../sessionsV2.types"; import StartSessionButton from "../StartSessionButton"; import EnvironmentItem from "./EnvironmentItem"; import EnvVariablesCard from "./EnvVariablesCard"; @@ -120,13 +124,15 @@ function SessionCardContent({ function SessionCard({ session, project, + launcherCategory, }: { session: SessionV2; project: Project; + launcherCategory: LauncherCategory; }) { return ( } contentLabel={} contentSession={ @@ -151,6 +157,7 @@ function SessionCardNotRunning({ launcher: SessionLauncher; project: Project; }) { + const launcherCategory = getLauncherCategory(launcher); return (

} @@ -187,18 +196,22 @@ function SessionCardNotRunning({ ); } -function getSessionColor(state: string) { - return state === "running" +function getSessionColor(state: string, launcherCategory: LauncherCategory) { + return state === "running" && launcherCategory === "session" ? "success" + : state === "running" && launcherCategory === "job" + ? "warning" : state === "starting" - ? "warning" - : state === "stopping" - ? "warning" - : state === "hibernated" - ? "dark" - : state === "failed" - ? "danger" - : "dark"; + ? "warning" + : state === "succeeded" + ? "success" + : state === "stopping" + ? "warning" + : state === "hibernated" + ? "dark" + : state === "failed" + ? "danger" + : "dark"; } interface SessionViewProps { @@ -238,16 +251,28 @@ export function SessionView({ const permissions = useProjectPermissions({ projectId: project.id }); const environment = launcher?.environment; + // for orphan session/jobs in case can't find the type we assume is a session + const orphanType = + !launcher && sessions && sessions?.length >= 1 + ? sessions[0].session_type + : null; + const orphanCategory = orphanType === "non-interactive" ? "job" : "session"; + + const launcherCategory = launcher && getLauncherCategory(launcher); + const launcherDefinition = getLauncherCategoryDefinition( + launcherCategory || orphanCategory + ); + const { data: dataConnectorLinks } = useGetProjectsByProjectIdDataConnectorLinksQuery({ projectId: project.id, }); const dataConnectorIds = dataConnectorLinks?.map( - (link) => link.data_connector_id, + (link) => link.data_connector_id ); const { data: dataConnectorsMap } = useGetDataConnectorsListByDataConnectorIdsQuery( - dataConnectorIds ? { dataConnectorIds } : skipToken, + dataConnectorIds ? { dataConnectorIds } : skipToken ); const dataConnectors = Object.values(dataConnectorsMap ?? {}); @@ -258,11 +283,13 @@ export function SessionView({ } = useGetClassesByClassIdQuery( launcher?.resource_class_id ? { classId: `${launcher.resource_class_id}` } - : skipToken, + : skipToken ); const totalSession = sessions ? Object.keys(sessions).length : 0; - const title = launcher ? launcher.name : "Orphan Session"; + const title = launcher + ? launcher.name + : `Orphan ${launcherDefinition?.text.inline} without launcher`; const launcherMenu = launcher && ( 0 - ? Object.keys(sessions)[0] - : "nn"; + ? Object.keys(sessions)[0] + : "nn"; const userLauncherResourcePool = useMemo( () => resourcePools?.find((pool) => - pool.classes.find((c) => c.id == launcher?.resource_class_id), + pool.classes.find((c) => c.id == launcher?.resource_class_id) ), - [launcher, resourcePools], + [launcher, resourcePools] ); const userLauncherResourceClass = useMemo( () => resourcePools ?.flatMap((pool) => pool.classes) .find((c) => c.id == launcher?.resource_class_id), - [launcher, resourcePools], + [launcher, resourcePools] ); const resourceDetails = @@ -329,9 +356,11 @@ export function SessionView({
{launcherMenu} @@ -342,7 +371,7 @@ export function SessionView({ - Launched Session + Launched {launcherDefinition?.text.display} {totalSession > 0 ? ( @@ -353,13 +382,18 @@ export function SessionView({ session={session} launcher={launcher} /> - +
)) ) : (

- No session is running from this launcher. + No {launcherDefinition?.text.inline} is running from this + launcher.

{launcher && (

- Session Environment + {launcherDefinition?.text.display} Environment

- Modify session environment + Modify {launcherDefinition?.text.inline} environment } @@ -428,7 +462,7 @@ export function SessionView({ className={cx( "align-items-center", "d-flex", - "justify-content-between", + "justify-content-between" )} >

)} - {launcher && ( + {launcher && launcherCategory && ( )} @@ -504,8 +539,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 ? ( @@ -588,7 +623,7 @@ export function SessionView({ className={cx( "align-items-center", "d-flex", - "justify-content-between", + "justify-content-between" )} >

diff --git a/client/src/features/sessionsV2/SessionsV2.tsx b/client/src/features/sessionsV2/SessionsV2.tsx index 7d7981d7ad..502f322145 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, @@ -49,7 +49,7 @@ import { SessionView } from "./SessionView/SessionView"; export function getShowSessionUrlByProject( project: Project, - sessionName: string, + sessionName: string ) { return generatePath(ABSOLUTE_ROUTES.v2.projects.show.sessions.show, { namespace: project.namespace, @@ -87,10 +87,10 @@ export default function SessionsV2({ project }: SessionsV2Props) { ? sessions.filter( (session) => launchers.every(({ id }) => session.launcher_id !== id) && - session.project_id === projectId, + session.project_id === projectId ) : [], - [launchers, sessions, projectId], + [launchers, sessions, projectId] ); const loading = isLoading && ( @@ -109,7 +109,7 @@ 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." + ? "Launchers are available to everyone who can see the project. Only you can see your running sessions and jobs." : "Define interactive environments in which to do your work and share it with others."}

{loading} @@ -140,13 +140,13 @@ export default function SessionsV2({ project }: SessionsV2Props) { className={cx( "align-items-center", "d-flex", - "justify-content-between", + "justify-content-between" )} >

- - Sessions + + Launchers

{totalSessions}
@@ -246,11 +246,11 @@ function OrphanSession({ session, project }: OrphanSessionProps) { const [hash, setHash] = useLocationHash(); const sessionHash = useMemo( () => `orphan-session-${session.name}`, - [session.name], + [session.name] ); const isSessionViewOpen = useMemo( () => hash === sessionHash, - [hash, sessionHash], + [hash, sessionHash] ); const toggleSessionView = useCallback(() => { setHash((prev) => { diff --git a/client/src/features/sessionsV2/StartSessionButton.tsx b/client/src/features/sessionsV2/StartSessionButton.tsx index 7e24b34507..73a8fc69b7 100644 --- a/client/src/features/sessionsV2/StartSessionButton.tsx +++ b/client/src/features/sessionsV2/StartSessionButton.tsx @@ -24,6 +24,8 @@ import { generatePath, Link } from "react-router"; import { UncontrolledTooltip } from "reactstrap"; import { useGetEnvironmentsByEnvironmentIdBuildsQuery as useGetBuildsQuery } from "~/features/sessionsV2/api/sessionLaunchersV2.api"; +import SubmitJobLauncherAction from "~/features/sessionsV2/components/SubmitJobLauncherAction"; +import { LauncherCategory } from "~/features/sessionsV2/sessionsV2.types"; import AppContext from "~/utils/context/appContext"; import { DEFAULT_APP_PARAMS } from "~/utils/context/appParams.constants"; import { ButtonWithMenuV2 } from "../../components/buttons/Button"; @@ -40,12 +42,15 @@ interface StartSessionButtonProps { useOldImage?: boolean; otherActions?: ReactNode; isDisabledDropdownToggle?: boolean; + launcherCategory: LauncherCategory; } export default function StartSessionButton({ launcher, namespace, slug, + launcherCategory, + isDisabledDropdownToggle, }: StartSessionButtonProps) { const startUrl = generatePath( ABSOLUTE_ROUTES.v2.projects.show.sessions.start, @@ -53,7 +58,7 @@ export default function StartSessionButton({ launcherId: launcher.id, namespace, slug, - }, + } ); const environment = launcher?.environment; const isExternalImageEnvironment = @@ -64,7 +69,7 @@ export default function StartSessionButton({ environment.environment_kind === "CUSTOM" && environment.container_image ? { imageUrl: environment.container_image } - : skipToken, + : skipToken ); const { params } = useContext(AppContext); const imageBuildersEnabled = @@ -72,11 +77,11 @@ export default function StartSessionButton({ const { data: builds } = useGetBuildsQuery( imageBuildersEnabled && environment.environment_image_source === "build" ? { environmentId: environment.id } - : skipToken, + : skipToken ); const hasSuccessfulBuild = builds?.find( - (build) => build.status === "succeeded", + (build) => build.status === "succeeded" ); const force = isExternalImageEnvironment && !isLoading && !data?.accessible; @@ -88,20 +93,28 @@ export default function StartSessionButton({ const launchAction = ( - - - {force ? "Force launch" : "Launch"} - + {launcherCategory === "session" ? ( + + + {force ? "Force launch" : "Launch"} + + ) : ( + + )} {isLaunchButtonDisabled && ( ); + if (launcherCategory === "job" && isDisabledDropdownToggle) + return launchAction; + return ( <> ({ 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; }; @@ -332,6 +330,8 @@ export type BuildParameters = { frontend_variant: FrontendVariant; repository_revision?: RepositoryRevision; context_dir?: BuildContextDir; + job_command?: EnvironmentCommand; + job_args?: EnvironmentArgs; }; export type EnvironmentImageSourceBuild = "build"; export type EnvironmentWithBuildGet = EnvironmentWithoutContainerImage & { @@ -350,7 +350,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; @@ -400,6 +400,8 @@ export type BuildParametersPatch = { frontend_variant?: FrontendVariant; repository_revision?: RepositoryRevisionPatch; context_dir?: BuildContextDirPatch; + job_command?: EnvironmentCommand; + job_args?: EnvironmentArgs; }; export type EnvironmentPatchInLauncher = EnvironmentPatch & { environment_kind?: EnvironmentKind; diff --git a/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json b/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json index 39d098d68d..61e4525f24 100644 --- a/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json +++ b/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json @@ -870,7 +870,7 @@ }, "LauncherType": { "type": "string", - "enum": ["interactive", "non_interactive"] + "enum": ["interactive", "non-interactive"] }, "SessionLaunchersList": { "description": "A list of Renku session launchers", @@ -1144,6 +1144,12 @@ }, "context_dir": { "$ref": "#/components/schemas/BuildContextDir" + }, + "job_command": { + "$ref": "#/components/schemas/EnvironmentCommand" + }, + "job_args": { + "$ref": "#/components/schemas/EnvironmentArgs" } }, "required": ["repository", "builder_variant", "frontend_variant"] @@ -1185,6 +1191,12 @@ }, "context_dir": { "$ref": "#/components/schemas/BuildContextDirPatch" + }, + "job_command": { + "$ref": "#/components/schemas/EnvironmentCommand" + }, + "job_args": { + "$ref": "#/components/schemas/EnvironmentArgs" } } }, 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/SessionForm/AdvancedSettingsFields.tsx b/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx index 60a5c269fe..f7d2c11202 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); @@ -206,7 +213,7 @@ function CheckboxOrRadioFormField({ ); } -interface JsonFieldProps { +export interface JsonFieldProps { control: Control; name: Path; label: string; @@ -214,17 +221,32 @@ interface JsonFieldProps { errors?: FieldErrors; helpText: string; isOptional?: boolean; + dataCy?: string; } -function JsonField({ +export function JsonField({ control, name, label, info, errors, helpText, - isOptional, + isOptional = true, + dataCy, }: 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 ( + <> +