- 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 ? (
diff --git a/client/src/features/sessionsV2/SessionsV2.tsx b/client/src/features/sessionsV2/SessionsV2.tsx
index 7d7981d7ad..b0bd45f894 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,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}
@@ -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/SessionForm/AdvancedSettingsFields.tsx b/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx
index 60a5c269fe..cb3aaf5b2e 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,34 @@ interface JsonFieldProps {
errors?: FieldErrors;
helpText: string;
isOptional?: boolean;
+ dataCy?: string;
+ id?: string;
}
-function JsonField({
+export function JsonField({
control,
name,
label,
info,
errors,
helpText,
- isOptional,
+ isOptional = true,
+ dataCy,
+ id,
}: 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 (
+ <>
+
+ {error && (
+
+ {error.message?.toString()}
+
+ )}
+ >
+ );
}}
- render={({ field }) => (
-
- )}
/>
- {errors?.[name] && (
-
- {errors[name]?.message?.toString()}
-
- )}
{helpText}
>
);
@@ -262,94 +294,102 @@ function JsonField({
interface AdvancedSettingsProp {
control: Control;
errors?: FieldErrors;
+ launcherCategory: LauncherCategory;
}
export function AdvancedSettingsFields<
T extends SessionLauncherForm | SessionEnvironmentForm,
->({ control, errors }: AdvancedSettingsProp) {
+>({ control, errors, launcherCategory = "session" }: AdvancedSettingsProp) {
return (
<>
-
-
-
- control={control}
- name={"default_url" as Path}
- label="Default URL"
- placeholder={DEFAULT_URL}
- errors={errors}
- info={ENVIRONMENT_VALUES_DESCRIPTION.urlPath}
- type="text"
- />
-
-
-
- control={control}
- name={"port" as Path}
- label="Port"
- isOptional={true}
- placeholder="e.g. 8080"
- info={ENVIRONMENT_VALUES_DESCRIPTION.port}
- type="number"
- rules={{ min: 1, max: 65535 }}
- />
-
-
-
- control={control}
- name={"mount_directory" as Path}
- label="Mount Directory"
- isOptional={true}
- errors={errors}
- info={ENVIRONMENT_VALUES_DESCRIPTION.mountDirectory}
- type="text"
- />
-
-
-
-
+ {launcherCategory === "session" && (
+ <>
+
+
+
+ control={control}
+ name={"default_url" as Path}
+ label="Default URL"
+ placeholder={DEFAULT_URL}
+ errors={errors}
+ info={ENVIRONMENT_VALUES_DESCRIPTION.urlPath}
+ type="text"
+ />
+
+
+
+ control={control}
+ name={"port" as Path}
+ label="Port"
+ isOptional={true}
+ placeholder="e.g. 8080"
+ info={ENVIRONMENT_VALUES_DESCRIPTION.port}
+ type="number"
+ rules={{ min: 1, max: 65535 }}
+ />
+
+
+
+ control={control}
+ name={"mount_directory" as Path}
+ label="Mount Directory"
+ isOptional={true}
+ errors={errors}
+ info={ENVIRONMENT_VALUES_DESCRIPTION.mountDirectory}
+ type="text"
+ />
+
+
+
+ >
+ )}
-
-
- control={control}
- name={"working_directory" as Path}
- label="Working Directory"
- isOptional={true}
- errors={errors}
- info={ENVIRONMENT_VALUES_DESCRIPTION.workingDirectory}
- type="text"
- />
-
-
-
- control={control}
- name={"uid" as Path}
- label="UID"
- isOptional={true}
- placeholder="e.g. 1000"
- type="number"
- errors={errors}
- info={ENVIRONMENT_VALUES_DESCRIPTION.uid}
- rules={{ min: 1, max: 65535 }}
- />
-
-
-
- control={control}
- name={"gid" as Path}
- label="GID"
- isOptional={true}
- placeholder="e.g. 1000"
- type="number"
- errors={errors}
- info={ENVIRONMENT_VALUES_DESCRIPTION.gid}
- rules={{ min: 1, max: 65535 }}
- />
-
+ {launcherCategory === "session" && (
+ <>
+
+
+ control={control}
+ name={"working_directory" as Path}
+ label="Working Directory"
+ isOptional={true}
+ errors={errors}
+ info={ENVIRONMENT_VALUES_DESCRIPTION.workingDirectory}
+ type="text"
+ />
+
+
+
+ control={control}
+ name={"uid" as Path}
+ label="UID"
+ isOptional={true}
+ placeholder="e.g. 1000"
+ type="number"
+ errors={errors}
+ info={ENVIRONMENT_VALUES_DESCRIPTION.uid}
+ rules={{ min: 1, max: 65535 }}
+ />
+
+
+
+ control={control}
+ name={"gid" as Path}
+ label="GID"
+ isOptional={true}
+ placeholder="e.g. 1000"
+ type="number"
+ errors={errors}
+ info={ENVIRONMENT_VALUES_DESCRIPTION.gid}
+ rules={{ min: 1, max: 65535 }}
+ />
+
+ >
+ )}
control={control}
@@ -358,7 +398,7 @@ export function AdvancedSettingsFields<
isOptional={true}
info={ENVIRONMENT_VALUES_DESCRIPTION.command}
errors={errors}
- helpText='Please enter a valid JSON array format e.g. ["python3","main.py"]'
+ helpText='Please enter a valid JSON array format e.g. ["python","my_repo/main.py"]'
/>
@@ -372,17 +412,19 @@ export function AdvancedSettingsFields<
helpText='Please enter a valid JSON array format e.g. ["--arg1", "--arg2", "--pwd=/home/user"]'
/>
-
-
- control={control}
- name={"strip_path_prefix" as Path}
- label="Strip session URL path prefix"
- isOptional={true}
- info={ENVIRONMENT_VALUES_DESCRIPTION.stripPathPrefix}
- errors={errors}
- type="checkbox"
- />
-
+ {launcherCategory === "session" && (
+
+
+ control={control}
+ name={"strip_path_prefix" as Path}
+ label="Strip session URL path prefix"
+ isOptional={true}
+ info={ENVIRONMENT_VALUES_DESCRIPTION.stripPathPrefix}
+ errors={errors}
+ type="checkbox"
+ />
+
+ )}
>
);
diff --git a/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx b/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx
index 6c8e3a4c82..a7ba4a840b 100644
--- a/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx
+++ b/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx
@@ -19,7 +19,7 @@
import { skipToken } from "@reduxjs/toolkit/query";
import cx from "classnames";
import { useContext, useMemo } from "react";
-import { type Control } from "react-hook-form";
+import { type Control, type FieldErrors } from "react-hook-form";
import { useProject } from "~/routes/projects/root";
import { ErrorAlert, WarnAlert } from "../../../../components/Alert";
@@ -28,7 +28,13 @@ import { Loader } from "../../../../components/Loader";
import AppContext from "../../../../utils/context/appContext";
import { DEFAULT_APP_PARAMS } from "../../../../utils/context/appParams.constants";
import { useGetRepositoriesQuery } from "../../../repositories/api/repositories.api";
-import type { SessionLauncherForm } from "../../sessionsV2.types";
+import { ENVIRONMENT_VALUES_DESCRIPTION } from "../../session.constants";
+import { getLauncherCategoryDefinition } from "../../session.utils";
+import type {
+ LauncherCategory,
+ SessionLauncherForm,
+} from "../../sessionsV2.types";
+import { JsonField } from "./AdvancedSettingsFields";
import BuilderAdvancedSettings from "./BuilderAdvancedSettings";
import BuilderFrontendSelector from "./BuilderFrontendSelector";
import BuilderTypeSelector from "./BuilderTypeSelector";
@@ -37,12 +43,16 @@ import CodeRepositorySelector from "./CodeRepositorySelector";
interface BuilderEnvironmentFieldsProps {
control: Control;
+ errors?: FieldErrors;
isEdit?: boolean;
+ launcherCategory: LauncherCategory;
}
export default function BuilderEnvironmentFields({
control,
+ errors,
isEdit,
+ launcherCategory,
}: BuilderEnvironmentFieldsProps) {
const { params } = useContext(AppContext);
const imageBuildersEnabled =
@@ -54,6 +64,7 @@ export default function BuilderEnvironmentFields({
const { data, isLoading, error } = useGetRepositoriesQuery(
repositories.length > 0 ? repositories : skipToken,
);
+ const categoryDefinition = getLauncherCategoryDefinition(launcherCategory);
const firstEligibleRepository = useMemo(
() =>
@@ -68,8 +79,9 @@ export default function BuilderEnvironmentFields({
if (!imageBuildersEnabled) {
return (
- Creating a session environment from code is not currently supported by
- this instance of RenkuLab. Contact an administrator to learn more.
+ Creating a {categoryDefinition.text.inline} environment from code is not
+ currently supported by this instance of RenkuLab. Contact an
+ administrator to learn more.
);
}
@@ -82,7 +94,7 @@ export default function BuilderEnvironmentFields({
) : repositories?.length == 0 ? (
No repositories found in this project. Add a repository first before
- creating a session environment from one.
+ creating a {categoryDefinition.text.inline} environment from one.
) : error || !data ? (
<>
@@ -92,7 +104,8 @@ export default function BuilderEnvironmentFields({
) : firstEligibleRepository == null || firstEligibleRepository < 0 ? (
No publicly accessible code repositories found in this project. RenkuLab
- can only build session environments from public code repositories.
+ can only build {categoryDefinition.text.inline} environments from public
+ code repositories.
) : (
@@ -105,8 +118,42 @@ export default function BuilderEnvironmentFields({
-
-
+ {launcherCategory === "session" && (
+
+ )}
+ {launcherCategory === "job" && (
+ <>
+
+
+ control={control}
+ name="command"
+ label="Job command"
+ info={ENVIRONMENT_VALUES_DESCRIPTION.command}
+ errors={errors}
+ helpText='Enter the command that will run as a job (JSON array format) e.g. ["python","my_repo/main.py"]'
+ isOptional={false}
+ dataCy="job-command-input"
+ id="job-command-input"
+ />
+
+
+
+ control={control}
+ name="args"
+ label="Job args"
+ info={ENVIRONMENT_VALUES_DESCRIPTION.args}
+ errors={errors}
+ helpText='Enter a valid JSON array format e.g. ["--arg1", "--arg2", "--pwd=/home/user"]'
+ isOptional={true}
+ dataCy="job-args-input"
+ id="job-args-input"
+ />
+
+ >
+ )}
+ {launcherCategory === "session" && (
+
+ )}
);
diff --git a/client/src/features/sessionsV2/components/SessionForm/CustomEnvironmentFields.tsx b/client/src/features/sessionsV2/components/SessionForm/CustomEnvironmentFields.tsx
index 84b87d0795..c264fbe58a 100644
--- a/client/src/features/sessionsV2/components/SessionForm/CustomEnvironmentFields.tsx
+++ b/client/src/features/sessionsV2/components/SessionForm/CustomEnvironmentFields.tsx
@@ -41,6 +41,7 @@ import InputOverlayLoader from "./InputOverlayLoader";
export function CustomEnvironmentFields({
control,
errors,
+ launcherCategory,
watch,
}: EnvironmentFieldsProps) {
const watchEnvironmentSelect = watch("environmentSelect");
@@ -68,9 +69,9 @@ export function CustomEnvironmentFields({
return (
- Use a custom container image to create a session launcher. Provide the
- image name or reference, such as one from Docker Hub (e.g.,
- repository/image:tag).
+ {launcherCategory === "job"
+ ? "Provide a container image for your job launcher, such as one from Docker Hub (e.g., repository/image:tag)."
+ : "Use a custom container image to create a session launcher. Provide the image name or reference, such as one from Docker Hub (e.g., repository/image:tag)."}
@@ -129,26 +130,31 @@ export function CustomEnvironmentFields({
)}
-
-
Advanced settings
-
-
-
- Please see the{" "}
- {" "}
- for how to complete this form to make your image run on Renkulab.
-
-
+
+ {launcherCategory === "session" && (
+ <>
+
Advanced settings
+
+
+ Please see the{" "}
+ {" "}
+ for how to complete this form to make your image run on
+ Renkulab.
+
+
+ >
+ )}
control={control}
errors={errors}
+ launcherCategory={launcherCategory}
/>
diff --git a/client/src/features/sessionsV2/components/SessionForm/EditLauncherFormContent.tsx b/client/src/features/sessionsV2/components/SessionForm/EditLauncherFormContent.tsx
index cca68ca133..b761b98580 100644
--- a/client/src/features/sessionsV2/components/SessionForm/EditLauncherFormContent.tsx
+++ b/client/src/features/sessionsV2/components/SessionForm/EditLauncherFormContent.tsx
@@ -42,8 +42,11 @@ import {
LAUNCHER_CONTAINER_IMAGE_QUERY_DEBOUNCE,
LAUNCHER_CONTAINER_IMAGE_VALIDATION_MESSAGE,
} from "../../session.constants";
-import { prioritizeSelectedEnvironment } from "../../session.utils";
-import { SessionLauncherForm } from "../../sessionsV2.types";
+import {
+ getLauncherCategoryDefinition,
+ prioritizeSelectedEnvironment,
+} from "../../session.utils";
+import { LauncherCategory, SessionLauncherForm } from "../../sessionsV2.types";
import { AdvancedSettingsFields } from "./AdvancedSettingsFields";
import BuilderEnvironmentFields from "./BuilderEnvironmentFields";
import EnvironmentKindField from "./EnvironmentKindField";
@@ -61,6 +64,7 @@ interface SessionLauncherFormContentProps {
interface EditLauncherFormContentProps extends SessionLauncherFormContentProps {
environmentId?: string;
+ launcherCategory: LauncherCategory;
}
export default function EditLauncherFormContent({
control,
@@ -68,6 +72,7 @@ export default function EditLauncherFormContent({
watch,
touchedFields,
environmentId,
+ launcherCategory,
}: EditLauncherFormContentProps) {
const watchEnvironmentSelect = watch("environmentSelect");
const watchContainerImage = watch("container_image");
@@ -216,26 +221,31 @@ export default function EditLauncherFormContent({
)}
-
-
Advanced settings
-
-
-
- Please see the{" "}
- {" "}
- for how to complete this form to make your image run on Renkulab.
-
-
+
+ {launcherCategory === "session" && (
+ <>
+
Advanced settings
+
+
+ Please see the{" "}
+ {" "}
+ for how to complete this form to make your image run on
+ Renkulab.
+
+
+ >
+ )}
control={control}
errors={errors}
+ launcherCategory={launcherCategory}
/>
>
@@ -243,13 +253,21 @@ export default function EditLauncherFormContent({
return (
-
+
{watchEnvironmentSelect === "global" && renderEnvironmentList()}
{watchEnvironmentSelect === "custom + image" &&
renderCustomEnvironmentFields()}
{watchEnvironmentSelect === "custom + build" && (
-
+
)}
);
@@ -258,12 +276,14 @@ export default function EditLauncherFormContent({
export function EditLauncherFormMetadata({
control,
errors,
+ launcherCategory,
}: EditLauncherFormContentProps) {
+ const launcherDefinition = getLauncherCategoryDefinition(launcherCategory);
return (
- Session launcher name
+ {launcherDefinition.text.display} launcher name
- Session launcher description
+ {launcherDefinition.text.display} launcher description
(Optional)
diff --git a/client/src/features/sessionsV2/components/SessionForm/EnvironmentField.tsx b/client/src/features/sessionsV2/components/SessionForm/EnvironmentField.tsx
index c7e1a33d79..986920d1e1 100644
--- a/client/src/features/sessionsV2/components/SessionForm/EnvironmentField.tsx
+++ b/client/src/features/sessionsV2/components/SessionForm/EnvironmentField.tsx
@@ -25,6 +25,8 @@ import {
UseFormWatch,
} from "react-hook-form";
+import { getLauncherCategoryDefinition } from "../../session.utils";
+import type { LauncherCategory } from "../../sessionsV2.types";
import { SessionLauncherForm } from "../../sessionsV2.types";
import BuilderEnvironmentFields from "./BuilderEnvironmentFields";
import { CustomEnvironmentFields } from "./CustomEnvironmentFields";
@@ -34,6 +36,7 @@ import { GlobalEnvironmentFields } from "./GlobalEnvironmentFields";
export interface EnvironmentFieldsProps {
control: Control
;
errors: FieldErrors;
+ launcherCategory: LauncherCategory;
setValue: UseFormSetValue;
touchedFields: Partial<
Readonly>
@@ -44,21 +47,29 @@ export interface EnvironmentFieldsProps {
export function EnvironmentFields({
control,
errors,
+ launcherCategory,
setValue,
touchedFields,
watch,
}: EnvironmentFieldsProps) {
const watchEnvironmentSelect = watch("environmentSelect");
+ const categoryDefinition = getLauncherCategoryDefinition(launcherCategory);
return (
-
1 of 2. Define environment
+
+ 1 of 2. Define {categoryDefinition.text.inline} environment
+
-
+
{watchEnvironmentSelect === "custom + build" && (
-
+
)}
);
diff --git a/client/src/features/sessionsV2/components/SessionForm/EnvironmentKindField.tsx b/client/src/features/sessionsV2/components/SessionForm/EnvironmentKindField.tsx
index 4701a7fadd..c685300108 100644
--- a/client/src/features/sessionsV2/components/SessionForm/EnvironmentKindField.tsx
+++ b/client/src/features/sessionsV2/components/SessionForm/EnvironmentKindField.tsx
@@ -23,179 +23,196 @@ import { ButtonGroup } from "reactstrap";
import AppContext from "../../../../utils/context/appContext";
import { DEFAULT_APP_PARAMS } from "../../../../utils/context/appParams.constants";
+import { getLauncherCategoryDefinition } from "../../session.utils";
+import type { LauncherCategory } from "../../sessionsV2.types";
import { SessionLauncherForm } from "../../sessionsV2.types";
import { EnvironmentIcon } from "./LauncherEnvironmentIcon";
interface EnvironmentKindFieldProps {
control: Control;
+ launcherCategory: LauncherCategory;
}
export default function EnvironmentKindField({
control,
+ launcherCategory,
}: EnvironmentKindFieldProps) {
const { params } = useContext(AppContext);
+ const categoryDefinition = getLauncherCategoryDefinition(launcherCategory);
const imageBuildersEnabled =
params?.IMAGE_BUILDERS_ENABLED ?? DEFAULT_APP_PARAMS.IMAGE_BUILDERS_ENABLED;
+ const showBuildOption =
+ imageBuildersEnabled &&
+ categoryDefinition.allowedEnvironmentSelects.includes("custom + build");
+ const showImageOption =
+ categoryDefinition.allowedEnvironmentSelects.includes("custom + image");
+ const showGlobalOption =
+ categoryDefinition.allowedEnvironmentSelects.includes("global");
return (
(
-
-
- field.onChange("global")}
- onBlur={field.onBlur}
- />
-
-
-
-
-
Global environment
+
+ {showGlobalOption && (
+ <>
+ field.onChange("global")}
+ onBlur={field.onBlur}
+ />
+
+
+
+
+ Global environment
+
+
+ Get started quickly with a pre-built environment.
+
-
- Get started quickly with a pre-built environment.
-
-
-
+
+ >
+ )}
- {imageBuildersEnabled && (
- <>
-
field.onChange("custom + build")}
- onBlur={field.onBlur}
- />
-
-
-
-
- Create from code
-
-
- Customize your session with a requirements.txt or similar
- file.
-
+ {showBuildOption && (
+ <>
+
field.onChange("custom + build")}
+ onBlur={field.onBlur}
+ />
+
+
+
+
+ Create from code
-
- >
- )}
+
+ {launcherCategory === "job"
+ ? "Build a container image from your code repository."
+ : "Customize your session with a requirements.txt or similar file."}
+
+
+
+ >
+ )}
-
field.onChange("custom + image")}
- onBlur={field.onBlur}
- />
-
-
-
-
-
External environment
+ {showImageOption && (
+ <>
+
field.onChange("custom + image")}
+ onBlur={field.onBlur}
+ />
+
+
+
+
+ External environment
+
+
+ Run a {categoryDefinition.text.inline} from a preexisting
+ container image.
+
-
- Run a session from a preexisting docker image.
-
-
-
-
-
+
+ >
+ )}
+
)}
/>
);
diff --git a/client/src/features/sessionsV2/components/SessionForm/LauncherCategoryIcon.tsx b/client/src/features/sessionsV2/components/SessionForm/LauncherCategoryIcon.tsx
new file mode 100644
index 0000000000..99dcc89cd0
--- /dev/null
+++ b/client/src/features/sessionsV2/components/SessionForm/LauncherCategoryIcon.tsx
@@ -0,0 +1,124 @@
+/*!
+ * 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 { SvgIconProps } from "~/features/sessionsV2/sessionsV2.types";
+
+function JobIconSvg({ className, style }: SvgIconProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SessionIconSvg({ className, style }: SvgIconProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export function LauncherCategoryIcon({
+ size = 110,
+ className,
+ type,
+}: {
+ size?: number;
+ className?: string;
+ type: "session" | "job";
+}) {
+ const iconMap = {
+ session: SessionIconSvg,
+ job: JobIconSvg,
+ };
+
+ const Icon = iconMap[type];
+
+ return (
+
+ );
+}
diff --git a/client/src/features/sessionsV2/components/SessionForm/LauncherDetailsFields.tsx b/client/src/features/sessionsV2/components/SessionForm/LauncherDetailsFields.tsx
index 50112c8cdb..1f919fa362 100644
--- a/client/src/features/sessionsV2/components/SessionForm/LauncherDetailsFields.tsx
+++ b/client/src/features/sessionsV2/components/SessionForm/LauncherDetailsFields.tsx
@@ -36,13 +36,21 @@ import {
MIN_SESSION_STORAGE_GB,
STEP_SESSION_STORAGE_GB,
} from "../../session.constants";
+import { getLauncherCategoryDefinition } from "../../session.utils";
+import type { LauncherCategory } from "../../sessionsV2.types";
import { SessionLauncherForm } from "../../sessionsV2.types";
import SessionClassSelector from "../SessionClassSelector";
interface LauncherDetailsFieldsProps {
control: Control
;
+ launcherCategory: LauncherCategory;
}
-export function LauncherDetailsFields({ control }: LauncherDetailsFieldsProps) {
+
+export function LauncherDetailsFields({
+ control,
+ launcherCategory,
+}: LauncherDetailsFieldsProps) {
+ const categoryDefinition = getLauncherCategoryDefinition(launcherCategory);
const {
data: resourcePools,
isLoading: isLoadingResourcesPools,
@@ -72,7 +80,7 @@ export function LauncherDetailsFields({ control }: LauncherDetailsFieldsProps) {
2 of 2. Define launcher details
- Session launcher name
+ {categoryDefinition.text.display} launcher name
- Session launcher compute resources
+ {categoryDefinition.text.display} launcher compute resources
{resourcePoolsError && (
) : (
- There are no one resource pool available to create a session
+ There are no one resource pool available to create a{" "}
+ {categoryDefinition.text.inline} launcher
)}
diff --git a/client/src/features/sessionsV2/components/SessionForm/LauncherEnvironmentIcon.tsx b/client/src/features/sessionsV2/components/SessionForm/LauncherEnvironmentIcon.tsx
index f100ece105..ad945a9a1d 100644
--- a/client/src/features/sessionsV2/components/SessionForm/LauncherEnvironmentIcon.tsx
+++ b/client/src/features/sessionsV2/components/SessionForm/LauncherEnvironmentIcon.tsx
@@ -17,8 +17,8 @@
*/
import cx from "classnames";
-import { CSSProperties } from "react";
+import { SvgIconProps } from "~/features/sessionsV2/sessionsV2.types.ts";
import { SessionLauncher } from "../../api/sessionLaunchersV2.generated-api";
export function LauncherEnvironmentIcon({
@@ -42,11 +42,6 @@ export function LauncherEnvironmentIcon({
) : null;
}
-interface SvgIconProps {
- className?: string;
- style?: CSSProperties;
-}
-
function CustomIconSvg({ className, style }: SvgIconProps) {
return (
{
- displayBuildActions: boolean;
- displayLaunchSession: boolean;
- imageCheckData: ImageCheckResponse | undefined;
- imageCheckLoading: boolean;
-}
-
-function SessionLauncherDefaultAction({
- displayBuildActions,
- displayLaunchSession,
- hasSession,
- imageCheckData,
- imageCheckLoading,
- launcher,
- namespace,
- slug,
-}: SessionLauncherDefaultAction) {
- const { environment } = launcher;
- const isExternalImageEnvironment =
- environment.environment_kind === "CUSTOM" &&
- environment.environment_image_source === "image";
-
- const [, setHash] = useLocationHash();
- const launcherHash = useMemo(() => `launcher-${launcher.id}`, [launcher.id]);
- const toggleLauncherView = useCallback(() => {
- setHash((prev) => {
- const isOpen = prev === launcherHash;
- return isOpen ? "" : launcherHash;
- });
- }, [launcherHash, setHash]);
-
- const startUrl = generatePath(
- ABSOLUTE_ROUTES.v2.projects.show.sessions.start,
- {
- launcherId: launcher.id,
- namespace,
- slug,
- },
- );
-
- if (imageCheckLoading)
- return (
-
- Checking launcher
-
- );
-
- const launchAction = displayLaunchSession && (
-
-
-
- Launch
-
-
- );
-
- if (displayBuildActions) {
- return (
- e.stopPropagation()}>
-
- {launchAction}
-
- );
- }
-
- if (
- displayLaunchSession &&
- (!isExternalImageEnvironment || imageCheckData?.accessible)
- ) {
- return launchAction;
- }
-
- return (
-
-
-
- Show launcher details
-
-
- );
-}
-
-interface SessionLauncherButtonsProps {
- hasSession?: boolean;
- lastBuild?: Build;
- launcher: SessionLauncher;
- namespace: string;
- otherActions?: ReactNode;
- slug: string;
- useOldImage?: boolean;
-}
-export function SessionLauncherButtons({
- hasSession,
- lastBuild,
- launcher,
- namespace,
- otherActions,
- slug,
- useOldImage,
-}: SessionLauncherButtonsProps) {
- const { environment } = launcher;
- const permissions = useProjectPermissions({ projectId: launcher.project_id });
- const isCodeEnvironment = environment.environment_image_source === "build";
- const isExternalImageEnvironment =
- environment.environment_kind === "CUSTOM" &&
- environment.environment_image_source === "image";
-
- const startUrl = generatePath(
- ABSOLUTE_ROUTES.v2.projects.show.sessions.start,
- {
- launcherId: launcher.id,
- namespace,
- slug,
- },
- );
- const { data, isLoading } = useGetSessionsImagesQuery(
- environment.environment_kind === "CUSTOM" && environment.container_image
- ? { imageUrl: environment.container_image }
- : skipToken,
- );
- const displayLaunchSession =
- !isCodeEnvironment ||
- (isCodeEnvironment && lastBuild?.status === "succeeded") ||
- (isCodeEnvironment && data?.accessible) ||
- (useOldImage ?? false);
-
- const displayBuildActions =
- isCodeEnvironment &&
- permissions.write &&
- (useOldImage || lastBuild?.status !== "succeeded");
-
- const defaultAction = (
-
- );
-
- const force = isExternalImageEnvironment && !isLoading && !data?.accessible;
-
- const customizeLaunch = displayLaunchSession && (
-
-
- {force ? "Force custom launch" : "Custom launch"}
-
- );
-
- const launchAnyway = displayLaunchSession && (
-
-
- {force ? "Force launch" : "Launch"}
-
- );
-
- if (!defaultAction) return null;
- return (
- <>
-
- {isExternalImageEnvironment && !data?.accessible && launchAnyway}
- {customizeLaunch}
- {isCodeEnvironment && permissions.write && !displayBuildActions && (
-
- )}
- {otherActions}
-
- {hasSession && displayLaunchSession && !data?.accessible === false ? (
-
- Cannot launch more than 1 session per session launcher.
-
- ) : useOldImage && !data?.accessible === false ? (
-
- Launch session using an older image
-
- ) : null}
- >
- );
-}
diff --git a/client/src/features/sessionsV2/components/SessionModals/ModifyResourcesLauncher.tsx b/client/src/features/sessionsV2/components/SessionModals/ModifyResourcesLauncher.tsx
index bd1f12fcac..e91f791e83 100644
--- a/client/src/features/sessionsV2/components/SessionModals/ModifyResourcesLauncher.tsx
+++ b/client/src/features/sessionsV2/components/SessionModals/ModifyResourcesLauncher.tsx
@@ -35,6 +35,8 @@ import {
import { SuccessAlert } from "~/components/Alert";
import { Loader } from "~/components/Loader";
+import { getLauncherCategoryDefinition } from "~/features/sessionsV2/session.utils";
+import { LauncherCategory } from "~/features/sessionsV2/sessionsV2.types";
import {
useGetResourcePoolsQuery,
type ResourceClassWithId,
@@ -56,6 +58,7 @@ interface ModifyResourcesLauncherModalProps {
resourceClassId?: number;
diskStorage?: number;
sessionLauncherId: string;
+ launcherCategory: LauncherCategory;
}
export function ModifyResourcesLauncherModal({
@@ -64,6 +67,7 @@ export function ModifyResourcesLauncherModal({
toggleModal,
resourceClassId,
diskStorage,
+ launcherCategory,
}: ModifyResourcesLauncherModalProps) {
const [updateSessionLauncher, result] = useUpdateSessionLauncherMutation();
const {
@@ -133,6 +137,7 @@ export function ModifyResourcesLauncherModal({
// eslint-disable-next-line react-hooks/incompatible-library
const watchCurrentSessionClass = watch("resourceClass");
const watchCurrentDiskStorage = watch("diskStorage");
+ const categoryDefinition = getLauncherCategoryDefinition(launcherCategory);
const selector = isLoadingResources ? (
@@ -190,16 +195,26 @@ export function ModifyResourcesLauncherModal({
Default resource class updated
- The session launcher’s default resource class has been changed.
- This change will apply the next time you launch a new session.
+ The {categoryDefinition.text.inline} launcher’s default resource
+ class has been changed. This change will apply the next time you{" "}
+ {categoryDefinition.text.action} a new session.
)}
These changes will apply the{" "}
- next time you launch a new session . If you wish to
- modify a currently running session, pause it and select ‘Modify
- session’ in the session options.
+
+ next time you {categoryDefinition.text.action} a new{" "}
+ {categoryDefinition.text.inline}
+
+ .
+ {launcherCategory === "session" && (
+
+ {" "}
+ If you wish to modify a currently running session, pause it and
+ select ‘Modify session’ in the session options.
+
+ )}
{selector}
{watchCurrentSessionClass && (
diff --git a/client/src/features/sessionsV2/components/SessionModals/NewSessionLauncherModal.tsx b/client/src/features/sessionsV2/components/SessionModals/NewLauncherCreateModal.tsx
similarity index 62%
rename from client/src/features/sessionsV2/components/SessionModals/NewSessionLauncherModal.tsx
rename to client/src/features/sessionsV2/components/SessionModals/NewLauncherCreateModal.tsx
index c28363f4a6..1f748075be 100644
--- a/client/src/features/sessionsV2/components/SessionModals/NewSessionLauncherModal.tsx
+++ b/client/src/features/sessionsV2/components/SessionModals/NewLauncherCreateModal.tsx
@@ -19,13 +19,7 @@
import { skipToken } from "@reduxjs/toolkit/query";
import cx from "classnames";
import { useCallback, useEffect, useMemo, useState } from "react";
-import {
- ArrowLeft,
- ArrowRight,
- CheckLg,
- PlayCircle,
- XLg,
-} from "react-bootstrap-icons";
+import { ArrowLeft, ArrowRight, CheckLg, XLg } from "react-bootstrap-icons";
import { useForm } from "react-hook-form";
import { useParams } from "react-router";
import {
@@ -45,23 +39,36 @@ import {
usePostSessionLaunchersMutation as useAddSessionLauncherMutation,
useGetEnvironmentsQuery as useGetSessionEnvironmentsQuery,
} from "../../api/sessionLaunchersV2.api";
-import { DEFAULT_PORT, DEFAULT_URL } from "../../session.constants";
-import { getFormattedEnvironmentValues } from "../../session.utils";
+import {
+ getFormattedEnvironmentValues,
+ getLauncherApiType,
+ getLauncherCategoryDefinition,
+ getNewLauncherFormDefaultValues,
+ isGlobalEnvironmentIncluded,
+} from "../../session.utils";
+import type { LauncherCategory } from "../../sessionsV2.types";
import { LauncherStep, SessionLauncherForm } from "../../sessionsV2.types";
import { EnvironmentFields } from "../SessionForm/EnvironmentField";
import { LauncherDetailsFields } from "../SessionForm/LauncherDetailsFields";
import scrollableModalStyles from "~/components/modal/ScrollableModal.module.scss";
-interface NewSessionLauncherModalProps {
+interface NewLauncherCreateModalProps {
isOpen: boolean;
+ launcherCategory: LauncherCategory;
toggle: () => void;
+ goBack: () => void;
}
-export default function NewSessionLauncherModal({
+export default function NewLauncherCreateModal({
isOpen,
+ launcherCategory,
toggle,
-}: NewSessionLauncherModalProps) {
+ goBack,
+}: NewLauncherCreateModalProps) {
+ const categoryDefinition = getLauncherCategoryDefinition(launcherCategory);
+ const HeaderIcon = categoryDefinition.icon;
+
const [step, setStep] = useState(LauncherStep.Environment);
const { namespace, slug } = useParams<{ namespace: string; slug: string }>();
const { data: environments } = useGetSessionEnvironmentsQuery({});
@@ -71,22 +78,21 @@ export default function NewSessionLauncherModal({
);
const projectId = project?.id;
+ const defaultEnvironmentSelect =
+ categoryDefinition.allowedEnvironmentSelects[0];
+
+ const defaultFormValues = useMemo(
+ () => getNewLauncherFormDefaultValues(defaultEnvironmentSelect),
+ [defaultEnvironmentSelect],
+ );
+
const useFormResult = useForm({
- defaultValues: {
- name: "",
- environmentSelect: "global",
- environmentId: "",
- container_image: "",
- default_url: DEFAULT_URL,
- port: DEFAULT_PORT,
- repository: "",
- platform: "",
- builder_variant: "python",
- },
+ defaultValues: defaultFormValues,
});
const {
control,
- formState: { errors, isDirty, touchedFields, isValid },
+ formState: { errors, touchedFields },
+ getValues,
handleSubmit,
reset,
setValue,
@@ -114,31 +120,62 @@ export default function NewSessionLauncherModal({
watchEnvironmentSelect,
]);
- const onNext = useCallback(() => {
- trigger([
- "args",
+ const touchFields = useCallback(
+ (fieldNames: (keyof SessionLauncherForm)[]) => {
+ fieldNames.forEach((fieldName) => {
+ setValue(fieldName, getValues(fieldName), {
+ shouldDirty: true,
+ shouldTouch: true,
+ });
+ });
+ },
+ [getValues, setValue],
+ );
+
+ const onNext = useCallback(async () => {
+ const fieldsToValidate: (keyof SessionLauncherForm)[] = [
"builder_variant",
- "command",
"container_image",
"environmentId",
"frontend_variant",
"repository",
- ]);
+ ];
- if (isDirty && isEnvironmentDefined && isValid)
+ if (watchEnvironmentSelect === "custom + image") {
+ fieldsToValidate.push("command", "args");
+ }
+
+ if (
+ launcherCategory === "job" &&
+ watchEnvironmentSelect === "custom + build"
+ ) {
+ fieldsToValidate.push("command", "args");
+ }
+
+ touchFields(fieldsToValidate);
+ const isValidStep = await trigger(fieldsToValidate, { shouldFocus: true });
+
+ if (isEnvironmentDefined && isValidStep) {
setStep(LauncherStep.LauncherDetails);
- }, [isDirty, setStep, trigger, isEnvironmentDefined, isValid]);
+ }
+ }, [
+ isEnvironmentDefined,
+ launcherCategory,
+ touchFields,
+ trigger,
+ watchEnvironmentSelect,
+ ]);
const onCancel = useCallback(() => {
setStep(LauncherStep.Environment);
- reset();
+ reset(defaultFormValues);
toggle();
- }, [reset, toggle, setStep]);
+ }, [defaultFormValues, reset, toggle]);
const onSubmit = useCallback(
(data: SessionLauncherForm) => {
- const { name, resourceClass } = data;
- const environment = getFormattedEnvironmentValues(data);
+ const { name, resourceClass, description } = data;
+ const environment = getFormattedEnvironmentValues(data, launcherCategory);
const diskStorage =
data.disk_storage && data.disk_storage != resourceClass.default_storage
? data.disk_storage
@@ -150,14 +187,13 @@ export default function NewSessionLauncherModal({
resource_class_id: resourceClass.id,
disk_storage: diskStorage,
name,
- // TODO: fix types for this session environment
-
+ description: description?.trim() ? description : undefined,
+ launcher_type: getLauncherApiType(launcherCategory),
environment: environment.data,
- launcher_type: "interactive",
},
});
},
- [projectId, addSessionLauncher],
+ [addSessionLauncher, launcherCategory, projectId],
);
useEffect(() => {
@@ -165,6 +201,11 @@ export default function NewSessionLauncherModal({
}, [watchEnvironmentCustomImage, trigger]);
useEffect(() => {
+ if (
+ !isGlobalEnvironmentIncluded(categoryDefinition.allowedEnvironmentSelects)
+ ) {
+ return;
+ }
trigger(["environmentId"]);
if (environments?.length) {
const environmentSelected = environments.find(
@@ -172,7 +213,13 @@ export default function NewSessionLauncherModal({
);
setValue("name", environmentSelected?.name ?? "");
}
- }, [watchEnvironmentId, setValue, environments, trigger]);
+ }, [
+ categoryDefinition.allowedEnvironmentSelects,
+ watchEnvironmentId,
+ setValue,
+ environments,
+ trigger,
+ ]);
useEffect(() => {
if (watchEnvironmentSelect === "custom + build" && watchBuilderVariant) {
@@ -187,23 +234,27 @@ export default function NewSessionLauncherModal({
}, [watchEnvironmentSelect, watchBuilderVariant, setValue]);
useEffect(() => {
+ if (
+ !isGlobalEnvironmentIncluded(categoryDefinition.allowedEnvironmentSelects)
+ ) {
+ return;
+ }
if (environments == null) {
return;
}
if (environments.length == 0) {
setValue("environmentSelect", "custom + image");
}
- }, [environments, setValue]);
+ }, [categoryDefinition.allowedEnvironmentSelects, environments, setValue]);
useEffect(() => {
if (!isOpen) {
setStep(LauncherStep.Environment);
- reset();
+ reset(defaultFormValues);
result.reset();
}
- }, [isOpen, reset, result, setStep]);
+ }, [defaultFormValues, isOpen, reset, result]);
- //? NOTE: the scrollable modal breaks when we use react-select inside it
return (
-
- Add session launcher
+
+ Create a new launcher - {categoryDefinition.text.display}
{result.isSuccess ? (
-
+
) : (
- {step === "environment" && (
- <>
-
- Define an interactive environment in which to do your work and
- share it with others.
-
- >
- )}
@@ -261,7 +310,7 @@ export default function NewSessionLauncherModal({
{result.isSuccess ? "Close" : "Cancel"}
- {!result.isSuccess && step == LauncherStep.LauncherDetails && (
+ {!result.isSuccess && step === LauncherStep.LauncherDetails && (
)}
- {!result.isSuccess && step === "environment" && (
+ {!result.isSuccess && step === LauncherStep.Environment && (
+ goBack()}
+ >
+
+ Back
+
+ )}
+ {!result.isSuccess && step === LauncherStep.Environment && (
Next
)}
- {!result.isSuccess && step === "launcherDetails" && (
+ {!result.isSuccess && step === LauncherStep.LauncherDetails && (
@@ -295,7 +354,7 @@ export default function NewSessionLauncherModal({
) : (
)}
- Add session launcher
+ Add {categoryDefinition.text.inline} launcher
)}
@@ -303,14 +362,20 @@ export default function NewSessionLauncherModal({
);
}
-const ConfirmationCreate = () => {
+function ConfirmationCreate({
+ launcherCategoryTitle,
+}: {
+ launcherCategoryTitle: string;
+}) {
return (
- Session launcher was created successfully!
+
+ {launcherCategoryTitle} launcher was created successfully!
+
);
-};
+}
diff --git a/client/src/features/sessionsV2/components/SessionModals/NewLauncherModal.module.scss b/client/src/features/sessionsV2/components/SessionModals/NewLauncherModal.module.scss
new file mode 100644
index 0000000000..a38bd2643b
--- /dev/null
+++ b/client/src/features/sessionsV2/components/SessionModals/NewLauncherModal.module.scss
@@ -0,0 +1,10 @@
+.LauncherOptionCard {
+ &:hover {
+ background-color: rgba(var(--bs-primary-rgb)) !important;
+ color: #fff !important;
+ }
+ img {
+ width: 112px;
+ height: auto;
+ }
+}
diff --git a/client/src/features/sessionsV2/components/SessionModals/NewLauncherModal.tsx b/client/src/features/sessionsV2/components/SessionModals/NewLauncherModal.tsx
new file mode 100644
index 0000000000..81a7e022d9
--- /dev/null
+++ b/client/src/features/sessionsV2/components/SessionModals/NewLauncherModal.tsx
@@ -0,0 +1,159 @@
+/*!
+ * 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 { useCallback, useState } from "react";
+import type { KeyboardEvent } from "react";
+import { RocketTakeoff } from "react-bootstrap-icons";
+import {
+ Card,
+ CardBody,
+ Col,
+ Modal,
+ ModalBody,
+ ModalHeader,
+ Row,
+} from "reactstrap";
+
+import { LauncherCategoryIcon } from "~/features/sessionsV2/components/SessionForm/LauncherCategoryIcon";
+import { getLauncherCategoryDefinition } from "~/features/sessionsV2/session.utils";
+import { LAUNCHER_OPTIONS } from "../../session.constants";
+import type { LauncherCategory } from "../../sessionsV2.types";
+import NewLauncherCreateModal from "./NewLauncherCreateModal";
+
+import styles from "./NewLauncherModal.module.scss";
+
+interface NewLauncherModalProps {
+ isOpen: boolean;
+ toggle: () => void;
+}
+
+export default function NewLauncherModal({
+ isOpen,
+ toggle,
+}: NewLauncherModalProps) {
+ const [selectedCategory, setSelectedCategory] =
+ useState(null);
+
+ const handleGoBack = () => {
+ setSelectedCategory(null);
+ };
+
+ const handleCloseAll = useCallback(() => {
+ setSelectedCategory(null);
+ toggle();
+ }, [toggle]);
+
+ const handleSelectCategory = useCallback((category: LauncherCategory) => {
+ setSelectedCategory(category);
+ }, []);
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent, category: LauncherCategory) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ handleSelectCategory(category);
+ }
+ },
+ [handleSelectCategory],
+ );
+
+ const showChooser = isOpen && selectedCategory == null;
+
+ return (
+ <>
+
+
+
+ Select the type of launcher to create
+
+
+
+ {LAUNCHER_OPTIONS.map((category) => {
+ const definition = getLauncherCategoryDefinition(category);
+ const OptionIcon = definition.icon;
+ return (
+
+ handleSelectCategory(category)}
+ onKeyDown={(event) => handleKeyDown(event, category)}
+ role="button"
+ aria-label={`Create ${definition.text.inline} launcher`}
+ tabIndex={0}
+ >
+
+
+
+
+
+
+
+
+ {definition.text.display}
+ {" "}
+ Launcher
+
+
+
+
+ {definition.description}
+
+
+
+
+
+ );
+ })}
+
+
+
+ {selectedCategory != null && (
+
+ )}
+ >
+ );
+}
diff --git a/client/src/features/sessionsV2/components/SessionModals/UpdateSessionLauncherMetadataModal.tsx b/client/src/features/sessionsV2/components/SessionModals/UpdateSessionLauncherMetadataModal.tsx
index b69cddd8e0..2ad92d56f0 100644
--- a/client/src/features/sessionsV2/components/SessionModals/UpdateSessionLauncherMetadataModal.tsx
+++ b/client/src/features/sessionsV2/components/SessionModals/UpdateSessionLauncherMetadataModal.tsx
@@ -32,9 +32,11 @@ import {
} from "../../api/sessionLaunchersV2.api";
import {
getFormattedEnvironmentValuesForEdit,
+ getLauncherCategory,
+ getLauncherCategoryDefinition,
getLauncherDefaultValues,
} from "../../session.utils";
-import { SessionLauncherForm } from "../../sessionsV2.types";
+import { LauncherCategory, SessionLauncherForm } from "../../sessionsV2.types";
import { EditLauncherFormMetadata } from "../SessionForm/EditLauncherFormContent";
import { UpdateSessionLauncherModalProps } from "./UpdateSessionLauncherModal";
@@ -49,7 +51,8 @@ export default function UpdateSessionLauncherMetadataModal({
() => getLauncherDefaultValues(launcher),
[launcher],
);
-
+ const launcherCategory = getLauncherCategory(launcher);
+ const launcherDefinition = getLauncherCategoryDefinition(launcherCategory);
const {
control,
formState: { errors, isDirty, touchedFields },
@@ -63,7 +66,10 @@ export default function UpdateSessionLauncherMetadataModal({
const onSubmit = useCallback(
(data: SessionLauncherForm) => {
const { description, name } = data;
- const environment = getFormattedEnvironmentValuesForEdit(data);
+ const environment = getFormattedEnvironmentValuesForEdit(
+ data,
+ launcherCategory,
+ );
if (environment.success && environment.data)
updateSessionLauncher({
launcherId: launcher.id,
@@ -74,7 +80,7 @@ export default function UpdateSessionLauncherMetadataModal({
},
});
},
- [launcher.id, updateSessionLauncher],
+ [launcher.id, launcherCategory, updateSessionLauncher],
);
useEffect(() => {
@@ -108,11 +114,11 @@ export default function UpdateSessionLauncherMetadataModal({
>
- Edit session launcher {launcher.name}
+ Edit {launcherDefinition.text.inline} launcher {launcher.name}
{result.isSuccess ? (
-
+
) : (
)}
@@ -148,7 +155,7 @@ export default function UpdateSessionLauncherMetadataModal({
) : (
)}
- Update session launcher
+ Update {launcherDefinition.text.inline} launcher
)}
@@ -156,16 +163,24 @@ export default function UpdateSessionLauncherMetadataModal({
);
}
-const ConfirmationUpdate = () => {
+const ConfirmationUpdate = ({
+ launcherCategory,
+}: {
+ launcherCategory: LauncherCategory;
+}) => {
+ const launcherDefinition = getLauncherCategoryDefinition(launcherCategory);
return (
- Session launcher metadata updated successfully!
+ {launcherDefinition.text.display} launcher metadata updated
+ successfully!
- The changes will take effect the next time you launch a session with
- this launcher. Current sessions will not be affected.
+ The changes will take effect the next time you{" "}
+ {launcherDefinition.text.action} a new{" "}
+ {launcherDefinition.text.inline} with this launcher. Current{" "}
+ {launcherDefinition.text.inline}s will not be affected.
diff --git a/client/src/features/sessionsV2/components/SessionModals/UpdateSessionLauncherModal.tsx b/client/src/features/sessionsV2/components/SessionModals/UpdateSessionLauncherModal.tsx
index 7846913341..26599b5e8f 100644
--- a/client/src/features/sessionsV2/components/SessionModals/UpdateSessionLauncherModal.tsx
+++ b/client/src/features/sessionsV2/components/SessionModals/UpdateSessionLauncherModal.tsx
@@ -33,9 +33,11 @@ import {
} from "../../api/sessionLaunchersV2.api";
import {
getFormattedEnvironmentValuesForEdit,
+ getLauncherCategory,
+ getLauncherCategoryDefinition,
getLauncherDefaultValues,
} from "../../session.utils";
-import { SessionLauncherForm } from "../../sessionsV2.types";
+import { LauncherCategory, SessionLauncherForm } from "../../sessionsV2.types";
import EditLauncherFormContent from "../SessionForm/EditLauncherFormContent";
import { EnvironmentIcon } from "../SessionForm/LauncherEnvironmentIcon";
@@ -50,6 +52,8 @@ export default function UpdateSessionLauncherEnvironmentModal({
launcher,
toggle,
}: UpdateSessionLauncherModalProps) {
+ const launcherCategory = getLauncherCategory(launcher);
+ const launcherDefinition = getLauncherCategoryDefinition(launcherCategory);
const { data: environments } = useGetSessionEnvironmentsQuery({});
const [updateSessionLauncher, result] = useUpdateSessionLauncherMutation();
const defaultValues = useMemo(
@@ -70,7 +74,10 @@ export default function UpdateSessionLauncherEnvironmentModal({
const onSubmit = useCallback(
(data: SessionLauncherForm) => {
const { description, name } = data;
- const environment = getFormattedEnvironmentValuesForEdit(data);
+ const environment = getFormattedEnvironmentValuesForEdit(
+ data,
+ launcherCategory,
+ );
if (environment.success && environment.data)
updateSessionLauncher({
launcherId: launcher.id,
@@ -81,7 +88,7 @@ export default function UpdateSessionLauncherEnvironmentModal({
},
});
},
- [launcher.id, updateSessionLauncher],
+ [launcher.id, updateSessionLauncher, launcherCategory],
);
useEffect(() => {
@@ -121,7 +128,7 @@ export default function UpdateSessionLauncherEnvironmentModal({
{result.isSuccess ? (
-
+
) : (
)}
@@ -157,7 +165,7 @@ export default function UpdateSessionLauncherEnvironmentModal({
) : (
)}
- Update session launcher
+ Update {launcherDefinition.text.inline} launcher
)}
@@ -165,16 +173,20 @@ export default function UpdateSessionLauncherEnvironmentModal({
);
}
-const ConfirmationUpdate = () => {
+interface ConfirmationUpdateProps {
+ launcherCategory: LauncherCategory;
+}
+const ConfirmationUpdate = ({ launcherCategory }: ConfirmationUpdateProps) => {
+ const launcherDefinition = getLauncherCategoryDefinition(launcherCategory);
return (
-
- Session launcher environment updated successfully!
-
+ Launcher environment updated successfully!
- The changes will take effect the next time you launch a session with
- this launcher. Current sessions will not be affected.
+ The changes will take effect the next time you{" "}
+ {launcherCategory === "session" ? "launch a session" : "run a job"}{" "}
+ with this launcher. Current {launcherDefinition.text.inline}s will not
+ be affected.
diff --git a/client/src/features/sessionsV2/components/launcherActions/LauncherActions.tsx b/client/src/features/sessionsV2/components/launcherActions/LauncherActions.tsx
new file mode 100644
index 0000000000..1050f36b1f
--- /dev/null
+++ b/client/src/features/sessionsV2/components/launcherActions/LauncherActions.tsx
@@ -0,0 +1,59 @@
+/*!
+ * 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 { getLauncherCategory } from "~/features/sessionsV2/session.utils";
+import JobLauncherActions from "./job/JobLauncherActions";
+import SessionLauncherActions from "./session/SessionLauncherActions";
+import type { LauncherActionsProps } from "./types";
+
+export function LauncherActions({
+ placement,
+ builds,
+ hasSession,
+ lastBuild,
+ launcher,
+ namespace,
+ otherActions,
+ slug,
+}: LauncherActionsProps) {
+ const category = getLauncherCategory(launcher);
+ return category === "session" ? (
+
+ ) : (
+
+ );
+}
diff --git a/client/src/features/sessionsV2/components/launcherActions/job/JobLauncherActions.tsx b/client/src/features/sessionsV2/components/launcherActions/job/JobLauncherActions.tsx
new file mode 100644
index 0000000000..2b91c36bd3
--- /dev/null
+++ b/client/src/features/sessionsV2/components/launcherActions/job/JobLauncherActions.tsx
@@ -0,0 +1,159 @@
+/*!
+ * 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 { Fragment, useMemo } from "react";
+import { ButtonGroup } from "reactstrap";
+
+import { ButtonWithMenuV2 } from "~/components/buttons/Button";
+import useProjectPermissions from "~/features/ProjectPageV2/utils/useProjectPermissions.hook";
+import { isTruthy } from "~/features/sessionsV2/session.utils";
+import useLauncherEnvironmentReadiness from "~/features/sessionsV2/useLauncherEnvironmentReadiness.hook";
+import BuildLauncherButtons, {
+ RebuildLauncherDropdownItem,
+} from "../../BuildLauncherButtons";
+import CheckingLauncherButton from "../shared/CheckingLauncherButton";
+import type { LauncherCardActionsProps } from "../types";
+import JobSubmitButton from "./JobSubmitButton";
+
+function getSubmitButtonClassName({
+ applyDefaultBuildActions,
+ hasMenuItems,
+}: {
+ applyDefaultBuildActions: boolean;
+ hasMenuItems: boolean;
+}) {
+ if (applyDefaultBuildActions) {
+ return "rounded-0";
+ }
+ if (hasMenuItems) {
+ return "rounded-end-0";
+ }
+ return "";
+}
+
+export default function JobLauncherActions({
+ builds,
+ lastBuild,
+ launcher,
+ otherActions,
+ displayBuildActions: displayBuildActionsProp,
+}: LauncherCardActionsProps) {
+ const { isLoadingPermissions, write } = useProjectPermissions({
+ projectId: launcher.project_id,
+ });
+
+ const {
+ isCodeEnvironment,
+ isLoadingContainerImage,
+ useOldImage: shouldUseOldImage,
+ hasValidImage,
+ imageStatus,
+ } = useLauncherEnvironmentReadiness({
+ builds,
+ launcher,
+ lastBuild,
+ });
+
+ const displayBuildActions =
+ displayBuildActionsProp && isCodeEnvironment && write;
+
+ // When only an old successful image is available or the last build failed,
+ // we prioritize inline build actions over placing them in the overflow menu.
+ const applyDefaultBuildActions = Boolean(
+ displayBuildActions &&
+ (shouldUseOldImage || lastBuild?.status !== "succeeded"),
+ );
+
+ const menuItems = [
+ displayBuildActions && !applyDefaultBuildActions && (
+
+ ),
+ write && otherActions && (
+ {otherActions}
+ ),
+ ].filter(isTruthy);
+ const hasMenuItems = menuItems.length > 0;
+
+ const submitButtonClassName = getSubmitButtonClassName({
+ applyDefaultBuildActions,
+ hasMenuItems,
+ });
+
+ const defaultAction = useMemo(() => {
+ if (isLoadingContainerImage) {
+ return ;
+ }
+
+ if (!write) {
+ return (
+
+ );
+ }
+
+ const submitAction = (
+
+ );
+
+ if (applyDefaultBuildActions) {
+ return (
+ e.stopPropagation()}>
+
+ {submitAction}
+
+ );
+ }
+
+ return submitAction;
+ }, [
+ applyDefaultBuildActions,
+ hasValidImage,
+ imageStatus,
+ isLoadingContainerImage,
+ launcher,
+ submitButtonClassName,
+ write,
+ ]);
+
+ if (isLoadingPermissions) {
+ return ;
+ }
+
+ if (!write || !hasMenuItems) {
+ return defaultAction;
+ }
+
+ return (
+
+ {menuItems}
+
+ );
+}
diff --git a/client/src/features/sessionsV2/components/launcherActions/job/JobSubmitButton.tsx b/client/src/features/sessionsV2/components/launcherActions/job/JobSubmitButton.tsx
new file mode 100644
index 0000000000..9eab0d7858
--- /dev/null
+++ b/client/src/features/sessionsV2/components/launcherActions/job/JobSubmitButton.tsx
@@ -0,0 +1,79 @@
+/*!
+ * 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 { useCallback, useRef } from "react";
+import { Send } from "react-bootstrap-icons";
+import { Button, UncontrolledTooltip } from "reactstrap";
+
+import { getLaunchActionTooltip } from "~/features/sessionsV2/session.utils";
+import { ImageStatus } from "~/features/sessionsV2/sessionsV2.types";
+
+interface JobSubmitButtonProps {
+ className?: string;
+ disabled?: boolean;
+ canWriteProject: boolean;
+ imageStatus: ImageStatus;
+}
+
+export default function JobSubmitButton({
+ className,
+ disabled = false,
+ imageStatus,
+ canWriteProject,
+}: JobSubmitButtonProps) {
+ const buttonRef = useRef(null);
+ const tooltipMessage = getLaunchActionTooltip(
+ canWriteProject,
+ imageStatus,
+ "job",
+ );
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (disabled) {
+ event.preventDefault();
+ }
+ },
+ [disabled],
+ );
+
+ return (
+ <>
+
+
+ Submit
+
+ {tooltipMessage ? (
+
+ {tooltipMessage}
+
+ ) : null}
+ >
+ );
+}
diff --git a/client/src/features/sessionsV2/components/launcherActions/session/SessionLauncherActions.tsx b/client/src/features/sessionsV2/components/launcherActions/session/SessionLauncherActions.tsx
new file mode 100644
index 0000000000..29b33da451
--- /dev/null
+++ b/client/src/features/sessionsV2/components/launcherActions/session/SessionLauncherActions.tsx
@@ -0,0 +1,200 @@
+/*!
+ * 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 { Fragment, useMemo } from "react";
+import { generatePath } from "react-router";
+import { ButtonGroup } from "reactstrap";
+
+import { ButtonWithMenuV2 } from "~/components/buttons/Button";
+import useProjectPermissions from "~/features/ProjectPageV2/utils/useProjectPermissions.hook";
+import { isTruthy } from "~/features/sessionsV2/session.utils";
+import useLauncherEnvironmentReadiness from "~/features/sessionsV2/useLauncherEnvironmentReadiness.hook";
+import { ABSOLUTE_ROUTES } from "~/routing/routes.constants";
+import { CUSTOM_LAUNCH_SEARCH_PARAM } from "../../../session.constants";
+import BuildLauncherButtons, {
+ RebuildLauncherDropdownItem,
+} from "../../BuildLauncherButtons";
+import CheckingLauncherButton from "../shared/CheckingLauncherButton";
+import SessionLaunchLink from "../shared/SessionLaunchLink";
+import ShowLauncherDetailsButton from "../shared/ShowLauncherDetailsButton";
+import type { LauncherCardActionsProps } from "../types";
+
+interface SessionLauncherCardActionsProps extends LauncherCardActionsProps {
+ alwaysShowLaunchAction?: boolean;
+}
+
+export default function SessionLauncherActions({
+ builds,
+ hasSession,
+ lastBuild,
+ launcher,
+ namespace,
+ otherActions,
+ slug,
+ alwaysShowLaunchAction = false,
+ displayBuildActions: displayBuildActionsProp,
+}: SessionLauncherCardActionsProps) {
+ const { isLoadingPermissions, write } = useProjectPermissions({
+ projectId: launcher.project_id,
+ });
+
+ const {
+ containerImage,
+ forceLaunch,
+ hasSuccessfulBuild,
+ isCodeEnvironment,
+ isLoadingContainerImage,
+ useOldImage: shouldUseOldImage,
+ imageStatus,
+ } = useLauncherEnvironmentReadiness({
+ builds,
+ launcher,
+ lastBuild,
+ });
+
+ const startUrl = generatePath(
+ ABSOLUTE_ROUTES.v2.projects.show.sessions.start,
+ {
+ launcherId: launcher.id,
+ namespace,
+ slug,
+ },
+ );
+
+ const displayBuildActions =
+ displayBuildActionsProp && isCodeEnvironment && write;
+
+ const applyDefaultBuildActions =
+ displayBuildActions &&
+ (shouldUseOldImage || lastBuild?.status !== "succeeded");
+
+ const customizeLaunchUrl = {
+ pathname: startUrl,
+ search: new URLSearchParams({
+ [CUSTOM_LAUNCH_SEARCH_PARAM]: "1",
+ }).toString(),
+ };
+
+ const displayLaunchButton =
+ !isCodeEnvironment ||
+ hasSuccessfulBuild ||
+ containerImage?.accessible ||
+ shouldUseOldImage;
+ const isLaunchDisabled = !!hasSession || !displayLaunchButton;
+ const menuItems = [
+ displayLaunchButton && (
+
+ ),
+ displayBuildActions && !applyDefaultBuildActions && (
+
+ ),
+ write && otherActions && (
+ {otherActions}
+ ),
+ ].filter(isTruthy);
+ const hasMenuItems = menuItems.length > 0;
+
+ const defaultAction = useMemo(() => {
+ if (isLoadingContainerImage) {
+ return ;
+ }
+
+ const launchAction = (
+
+ );
+
+ if (applyDefaultBuildActions) {
+ return (
+ e.stopPropagation()}>
+
+ {displayLaunchButton && launchAction}
+
+ );
+ }
+
+ if (displayLaunchButton || alwaysShowLaunchAction) {
+ return launchAction;
+ }
+
+ return (
+
+ );
+ }, [
+ applyDefaultBuildActions,
+ forceLaunch,
+ hasSession,
+ isLaunchDisabled,
+ isLoadingContainerImage,
+ launcher,
+ displayLaunchButton,
+ startUrl,
+ write,
+ imageStatus,
+ hasMenuItems,
+ alwaysShowLaunchAction,
+ ]);
+
+ // Keep this guard after hooks and useMemo to preserve React hook call order.
+ if (isLoadingPermissions) return ;
+
+ return !hasMenuItems ? (
+ defaultAction
+ ) : (
+
+ {menuItems}
+
+ );
+}
diff --git a/client/src/features/sessionsV2/components/launcherActions/shared/CheckingLauncherButton.tsx b/client/src/features/sessionsV2/components/launcherActions/shared/CheckingLauncherButton.tsx
new file mode 100644
index 0000000000..de4d56b511
--- /dev/null
+++ b/client/src/features/sessionsV2/components/launcherActions/shared/CheckingLauncherButton.tsx
@@ -0,0 +1,30 @@
+/*!
+ * 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 { Button } from "reactstrap";
+
+import { Loader } from "~/components/Loader";
+
+export default function CheckingLauncherButton() {
+ return (
+
+
+ Checking launcher
+
+ );
+}
diff --git a/client/src/features/sessionsV2/components/launcherActions/shared/SessionLaunchLink.tsx b/client/src/features/sessionsV2/components/launcherActions/shared/SessionLaunchLink.tsx
new file mode 100644
index 0000000000..caf44342e4
--- /dev/null
+++ b/client/src/features/sessionsV2/components/launcherActions/shared/SessionLaunchLink.tsx
@@ -0,0 +1,90 @@
+/*!
+ * 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 { useCallback, useRef } from "react";
+import { PlayCircle } from "react-bootstrap-icons";
+import { Link, type To } from "react-router";
+import { UncontrolledTooltip } from "reactstrap";
+
+import { getLaunchActionTooltip } from "~/features/sessionsV2/session.utils";
+import { ImageStatus } from "~/features/sessionsV2/sessionsV2.types";
+
+interface SessionLaunchLinkProps {
+ className?: string;
+ isCustomLaunch?: boolean;
+ isDisabled?: boolean;
+ alreadyRunningSession: boolean;
+ label: string;
+ to: To;
+ canWriteProject: boolean;
+ imageStatus: ImageStatus;
+}
+
+function SessionLaunchLink({
+ className,
+ isCustomLaunch,
+ isDisabled = false,
+ label,
+ to,
+ alreadyRunningSession,
+ canWriteProject,
+ imageStatus,
+}: SessionLaunchLinkProps) {
+ const linkRef = useRef(null);
+ const tooltipContent = alreadyRunningSession
+ ? "Cannot launch more than 1 session per session launcher."
+ : getLaunchActionTooltip(canWriteProject, imageStatus, "session");
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (isDisabled) {
+ event.preventDefault();
+ }
+ },
+ [isDisabled],
+ );
+
+ return (
+ <>
+
+
+ {label}
+
+ {tooltipContent ? (
+
+ {tooltipContent}
+
+ ) : null}
+ >
+ );
+}
+
+export default SessionLaunchLink;
diff --git a/client/src/features/sessionsV2/components/launcherActions/shared/ShowLauncherDetailsButton.tsx b/client/src/features/sessionsV2/components/launcherActions/shared/ShowLauncherDetailsButton.tsx
new file mode 100644
index 0000000000..157bebaad9
--- /dev/null
+++ b/client/src/features/sessionsV2/components/launcherActions/shared/ShowLauncherDetailsButton.tsx
@@ -0,0 +1,53 @@
+/*!
+ * 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 { useCallback, useMemo } from "react";
+import { Button } from "reactstrap";
+
+import useLocationHash from "~/utils/customHooks/useLocationHash.hook";
+
+interface ShowLauncherDetailsButtonProps {
+ launcherId: string;
+ className?: string;
+}
+
+export default function ShowLauncherDetailsButton({
+ launcherId,
+ className,
+}: ShowLauncherDetailsButtonProps) {
+ const [, setHash] = useLocationHash();
+ const launcherHash = useMemo(() => `launcher-${launcherId}`, [launcherId]);
+ const toggleLauncherView = useCallback(() => {
+ setHash((prev) => {
+ const isOpen = prev === launcherHash;
+ return isOpen ? "" : launcherHash;
+ });
+ }, [launcherHash, setHash]);
+
+ return (
+
+ Show launcher details
+
+ );
+}
diff --git a/client/src/features/sessionsV2/components/launcherActions/types.ts b/client/src/features/sessionsV2/components/launcherActions/types.ts
new file mode 100644
index 0000000000..73d0b7b1a6
--- /dev/null
+++ b/client/src/features/sessionsV2/components/launcherActions/types.ts
@@ -0,0 +1,44 @@
+/*!
+ * Copyright 2026 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ReactNode } from "react";
+
+import type { Build, SessionLauncher } from "../../api/sessionLaunchersV2.api";
+
+export type LauncherActionPlacement = "launcher-card" | "launcher-side-panel";
+
+export interface LauncherCardActionsProps {
+ builds?: Build[];
+ hasSession?: boolean;
+ lastBuild?: Build;
+ launcher: SessionLauncher;
+ namespace: string;
+ otherActions?: ReactNode;
+ slug: string;
+ displayBuildActions?: boolean;
+}
+
+export type LauncherPanelActionsProps = Pick<
+ LauncherCardActionsProps,
+ "hasSession" | "launcher" | "namespace" | "slug"
+>;
+
+export interface LauncherActionsProps
+ extends LauncherCardActionsProps, LauncherPanelActionsProps {
+ placement: LauncherActionPlacement;
+}
diff --git a/client/src/features/sessionsV2/launcherEnvironment.utils.ts b/client/src/features/sessionsV2/launcherEnvironment.utils.ts
new file mode 100644
index 0000000000..1ffefcd3de
--- /dev/null
+++ b/client/src/features/sessionsV2/launcherEnvironment.utils.ts
@@ -0,0 +1,46 @@
+/*!
+ * Copyright 2026 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { SessionLauncher } from "./api/sessionLaunchersV2.api";
+import type { EnvironmentSelectOption } from "./sessionsV2.types";
+
+export function getLauncherEnvironmentSelect(
+ launcher: SessionLauncher,
+): EnvironmentSelectOption | undefined {
+ const { environment } = launcher;
+ if (environment.environment_kind === "GLOBAL") {
+ return "global";
+ }
+ if (environment.environment_kind === "CUSTOM") {
+ if (environment.environment_image_source === "build")
+ return "custom + build";
+ return "custom + image";
+ }
+ return undefined;
+}
+
+export function getLauncherEnvironmentFlags(launcher: SessionLauncher) {
+ const environmentSelect = getLauncherEnvironmentSelect(launcher);
+
+ return {
+ environmentSelect,
+ isGlobalEnvironment: environmentSelect === "global",
+ isCustomImageEnvironment: environmentSelect === "custom + image",
+ isCodeEnvironment: environmentSelect === "custom + build",
+ };
+}
diff --git a/client/src/features/sessionsV2/session.constants.tsx b/client/src/features/sessionsV2/session.constants.tsx
index ff96457243..18f82d29d4 100644
--- a/client/src/features/sessionsV2/session.constants.tsx
+++ b/client/src/features/sessionsV2/session.constants.tsx
@@ -16,6 +16,8 @@
* limitations under the License
*/
+import { Gear, PlayCircle } from "react-bootstrap-icons";
+
import { NEW_DOCS_CREATE_ENV_CUSTOM_PACKAGES_INSTALLED } from "~/utils/constants/NewDocs";
import faviconICO from "../../styles/assets/favicon/Favicon.ico";
import faviconSVG from "../../styles/assets/favicon/Favicon.svg";
@@ -37,7 +39,11 @@ import faviconWaitingICO from "../../styles/assets/favicon/FaviconWaiting.ico";
import faviconWaitingSVG from "../../styles/assets/favicon/FaviconWaiting.svg";
import faviconWaiting16px from "../../styles/assets/favicon/FaviconWaiting16px.png";
import faviconWaiting32px from "../../styles/assets/favicon/FaviconWaiting32px.png";
-import { BuilderSelectorOption } from "./sessionsV2.types";
+import type {
+ BuilderSelectorOption,
+ LauncherCategory,
+ LauncherCategoryDefinition,
+} from "./sessionsV2.types";
export const DEFAULT_URL = "/";
export const DEFAULT_PORT = 8888;
@@ -186,6 +192,12 @@ export const LAUNCHER_CONTAINER_IMAGE_VALIDATION_MESSAGE = {
pattern: "Please provide a valid container image.",
};
+export const JOB_COMMAND_VALIDATION_MESSAGE = {
+ required: "Job command is required.",
+ invalid: "Invalid job command format.",
+ empty: "Job command can't be empty.",
+};
+
export const LAUNCHER_CONTAINER_IMAGE_QUERY_DEBOUNCE = 1_000;
export const PAUSE_SESSION_WARNING_DEBOUNCE_SECONDS = 30;
@@ -197,3 +209,34 @@ export const DEFAULT_POLLING_INTERVAL_MS = 5_000;
export const MIN_SESSION_STORAGE_GB = 1;
export const STEP_SESSION_STORAGE_GB = 1;
+
+export const LAUNCHER_OPTIONS: LauncherCategory[] = ["session", "job"];
+
+export const LAUNCHER_BY_CATEGORY: Record<
+ LauncherCategory,
+ LauncherCategoryDefinition
+> = {
+ session: {
+ apiType: "interactive",
+ text: {
+ display: "Session",
+ inline: "session",
+ action: "launch",
+ },
+ icon: PlayCircle,
+ description:
+ "Create an interactive environment for coding and data exploration.",
+ allowedEnvironmentSelects: ["global", "custom + build", "custom + image"],
+ },
+ job: {
+ apiType: "non-interactive",
+ text: {
+ display: "Job",
+ inline: "job",
+ action: "submit",
+ },
+ icon: Gear,
+ description: "Run a process in the background.",
+ allowedEnvironmentSelects: ["custom + build", "custom + image"],
+ },
+};
diff --git a/client/src/features/sessionsV2/session.utils.ts b/client/src/features/sessionsV2/session.utils.ts
index 5925293aff..52453f4b4e 100644
--- a/client/src/features/sessionsV2/session.utils.ts
+++ b/client/src/features/sessionsV2/session.utils.ts
@@ -19,6 +19,7 @@
import { FaviconStatus } from "../display/display.types";
import type { ResourcePoolWithId } from "./api/computeResources.api";
import type {
+ LauncherType,
EnvironmentList as SessionEnvironmentList,
SessionLauncher,
SessionLauncherEnvironmentParams,
@@ -26,15 +27,99 @@ import type {
} from "./api/sessionLaunchersV2.api";
import type { ImageCheckResponse } from "./api/sessionsV2.api";
import {
+ BUILDER_FRONTEND_COMBINATIONS,
BUILDER_PLATFORMS,
+ DEFAULT_PORT,
DEFAULT_URL,
ENV_VARIABLES_RESERVED_PREFIX,
+ getCompatibleFrontends,
+ LAUNCHER_BY_CATEGORY,
} from "./session.constants";
-import type {
+import {
+ EnvironmentSelectOption,
+ ImageStatus,
+ LauncherCategory,
+ LauncherCategoryDefinition,
SessionLauncherForm,
SessionStatusState,
} from "./sessionsV2.types";
+export function getLauncherCategoryDefinitionByLauncher(
+ launcher: SessionLauncher,
+): LauncherCategoryDefinition {
+ return isJobLauncher(launcher)
+ ? LAUNCHER_BY_CATEGORY["job"]
+ : LAUNCHER_BY_CATEGORY["session"];
+}
+
+export function getLauncherCategory(
+ launcher: SessionLauncher,
+): LauncherCategory {
+ return isJobLauncher(launcher) ? "job" : "session";
+}
+
+export function getLauncherCategoryDefinition(
+ category: LauncherCategory,
+): LauncherCategoryDefinition {
+ return LAUNCHER_BY_CATEGORY[category];
+}
+
+export function getLauncherApiType(category: LauncherCategory): LauncherType {
+ return getLauncherCategoryDefinition(category).apiType;
+}
+
+export function isJobLauncher(launcher: SessionLauncher): boolean {
+ return launcher.launcher_type === "non-interactive";
+}
+
+export function isGlobalEnvironmentIncluded(allowedEnvironments: string[]) {
+ return allowedEnvironments.includes("global");
+}
+
+/**
+ * Type-safe predicate for filtering out falsy optional entries.
+ */
+export function isTruthy(
+ value: T | false | null | undefined,
+): value is Exclude {
+ return Boolean(value);
+}
+
+export function getNewLauncherFormDefaultValues(
+ environmentSelect: EnvironmentSelectOption,
+): Pick<
+ SessionLauncherForm,
+ | "name"
+ | "description"
+ | "environmentSelect"
+ | "environmentId"
+ | "container_image"
+ | "default_url"
+ | "port"
+ | "repository"
+ | "platform"
+ | "builder_variant"
+ | "frontend_variant"
+ | "command"
+ | "args"
+> {
+ return {
+ name: "",
+ description: "",
+ environmentSelect,
+ environmentId: "",
+ container_image: "",
+ default_url: DEFAULT_URL,
+ port: DEFAULT_PORT,
+ repository: "",
+ platform: "",
+ builder_variant: "python",
+ frontend_variant: "jupyterlab", // eslint-disable-line spellcheck/spell-checker
+ command: "",
+ args: "",
+ };
+}
+
export function getSessionFavicon(
sessionState?: SessionStatusState,
isLoading?: boolean,
@@ -90,7 +175,10 @@ export function prioritizeSelectedEnvironment(
* - `data`: If `success` is true, contains the formatted `SessionLauncherEnvironmentParams` object.
* - `error`: If `success` is false, contains a string describing the error (e.g., "Invalid command or args format").
*/
-export function getFormattedEnvironmentValues(data: SessionLauncherForm): {
+export function getFormattedEnvironmentValues(
+ data: SessionLauncherForm,
+ launcherCategory: LauncherCategory = "session",
+): {
success: boolean;
data?: SessionLauncherEnvironmentParams;
error?: string;
@@ -128,18 +216,46 @@ export function getFormattedEnvironmentValues(data: SessionLauncherForm): {
BUILDER_PLATFORMS.map(({ value }) => value).find(
(value) => value === platform_,
) ?? BUILDER_PLATFORMS[0].value;
- return {
- success: true,
- data: {
- environment_image_source: "build",
- builder_variant,
+ const isCompatible =
+ BUILDER_FRONTEND_COMBINATIONS[builder_variant]?.includes(
frontend_variant,
- repository,
- platforms: [platform],
- ...(context_dir ? { context_dir } : {}),
- ...(repository_revision ? { repository_revision } : {}),
- },
+ ) ?? true;
+ const buildPayload: SessionLauncherEnvironmentParams = {
+ environment_image_source: "build",
+ builder_variant,
+ frontend_variant: isCompatible
+ ? frontend_variant
+ : getCompatibleFrontends(builder_variant)[0] || "jupyterlab", // eslint-disable-line spellcheck/spell-checker
+ repository,
+ platforms: [platform],
+ ...(context_dir ? { context_dir } : {}),
+ ...(repository_revision ? { repository_revision } : {}),
};
+
+ if (launcherCategory === "job") {
+ if (!command?.trim()) {
+ return { success: false, error: "Job command is required" };
+ }
+ const commandFormatted = safeParseJSONStringArray(command);
+ if (!commandFormatted.parsed) {
+ return { success: false, error: "Invalid job command format" };
+ }
+ if (commandFormatted.data == null || commandFormatted.data.length === 0) {
+ return { success: false, error: "Job command can't be empty" };
+ }
+ buildPayload.command = commandFormatted.data;
+ }
+ if (launcherCategory === "job" && args?.trim()) {
+ const argsFormatted = safeParseJSONStringArray(args);
+ if (!argsFormatted.parsed) {
+ return { success: false, error: "Invalid job args format" };
+ }
+ if (argsFormatted.data == null || argsFormatted.data.length === 0) {
+ return { success: false, error: "Job args can't be empty" };
+ }
+ buildPayload.args = argsFormatted.data;
+ }
+ return { success: true, data: buildPayload };
}
const commandFormatted = safeParseJSONStringArray(command);
@@ -180,6 +296,7 @@ export function getFormattedEnvironmentValues(data: SessionLauncherForm): {
*/
export function getFormattedEnvironmentValuesForEdit(
data: SessionLauncherForm,
+ launcherCategory: LauncherCategory,
): {
success: boolean;
data?: SessionLauncherEnvironmentPatchParams;
@@ -187,17 +304,19 @@ export function getFormattedEnvironmentValuesForEdit(
} {
const { environmentSelect } = data;
+ const result = getFormattedEnvironmentValues(data, launcherCategory);
+ if (!result.success) {
+ return result;
+ }
+ const commandParsed = safeParseJSONStringArray(data.command);
+ const argsParsed = safeParseJSONStringArray(data.args);
+
if (
environmentSelect === "global" ||
environmentSelect === "custom + image"
) {
- const result = getFormattedEnvironmentValues(data);
- if (!result.success) {
- return result;
- }
const { data: environment } = result;
- const commandParsed = safeParseJSONStringArray(data.command);
- const argsParsed = safeParseJSONStringArray(data.args);
+
return {
...result,
data: {
@@ -223,11 +342,17 @@ export function getFormattedEnvironmentValuesForEdit(
(value) => value === platform_,
) ?? BUILDER_PLATFORMS[0].value;
+ if (launcherCategory === "job" && !commandParsed.data?.length) {
+ return { success: false, error: "Job command is required" };
+ }
+
return {
success: true,
data: {
environment_image_source: "build",
environment_kind: "CUSTOM",
+ ...(commandParsed.data && { command: commandParsed.data }),
+ ...(argsParsed.data && { args: argsParsed.data }),
build_parameters: {
builder_variant,
frontend_variant,
@@ -367,6 +492,30 @@ export function isValidJSONStringArray(
return parseString.error ?? "Is not a valid JSON array string";
}
+/**
+ * Validates a JSON string array field that must be present and non-empty.
+ */
+export function isValidRequiredJSONStringArray(
+ value: string,
+ requiredMessage = "Job command is required.",
+ emptyMessage = "Job command can't be empty.",
+): true | string {
+ if (!value?.toString().trim()) {
+ return requiredMessage;
+ }
+
+ const validationResult = isValidJSONStringArray(value);
+ if (validationResult === true) {
+ const parsed = safeParseJSONStringArray(value);
+ if (!parsed.data?.length) {
+ return emptyMessage;
+ }
+ return true;
+ }
+
+ return validationResult ?? requiredMessage;
+}
+
/**
* Ensure a given URL uses the HTTPS protocol.
* If the URL already starts with "https://", it is returned unchanged.
@@ -419,3 +568,24 @@ export function isImageCompatibleWith(
);
return imagePlatforms.some((p) => p === platform);
}
+
+export function getLaunchActionTooltip(
+ projectWritePermission: boolean,
+ imageStatus: ImageStatus,
+ launchCategory: LauncherCategory,
+): string | undefined {
+ const categoryDefinition = getLauncherCategoryDefinition(launchCategory);
+
+ switch (imageStatus) {
+ case "only-old-image-available":
+ return `Launch ${categoryDefinition.text.inline} using an older image`;
+
+ case "no-available":
+ return projectWritePermission
+ ? "No image available. Run the Build action to generate an image."
+ : "No image available. Copy the project and run the Build action to generate an image.";
+
+ case "available":
+ return undefined;
+ }
+}
diff --git a/client/src/features/sessionsV2/sessionsV2.types.ts b/client/src/features/sessionsV2/sessionsV2.types.ts
index 0e4ea17f62..e16338f4c5 100644
--- a/client/src/features/sessionsV2/sessionsV2.types.ts
+++ b/client/src/features/sessionsV2/sessionsV2.types.ts
@@ -16,8 +16,13 @@
* limitations under the License.
*/
-import type { ReactNode } from "react";
+import { CSSProperties, ReactNode } from "react";
+import { Icon } from "react-bootstrap-icons";
+import {
+ SessionType,
+ SubmissionId,
+} from "~/features/sessionsV2/api/sessionsV2.generated-api";
import type { ResourceClassWithId } from "./api/computeResources.api";
import type {
BuildParametersPost,
@@ -27,9 +32,36 @@ import type {
EnvironmentPort,
EnvironmentPost,
EnvironmentUid,
+ LauncherType,
SessionLauncherPost,
} from "./api/sessionLaunchersV2.api";
+export interface SvgIconProps {
+ className?: string;
+ style?: CSSProperties;
+}
+
+export type LauncherCategory = "session" | "job";
+
+export type LauncherApiType = LauncherType;
+
+export type EnvironmentSelectOption =
+ | "global"
+ | "custom + image"
+ | "custom + build";
+
+export interface LauncherCategoryDefinition {
+ apiType: LauncherApiType;
+ text: {
+ display: string;
+ inline: string;
+ action: string;
+ };
+ icon: Icon;
+ description: string;
+ allowedEnvironmentSelects: EnvironmentSelectOption[];
+}
+
export interface SessionLauncherForm
extends
Pick<
@@ -51,7 +83,7 @@ export interface SessionLauncherForm
resourceClass: ResourceClassWithId;
// Substitute for Environment Kind and Environment Image Source in forms
- environmentSelect: "global" | "custom + image" | "custom + build";
+ environmentSelect: EnvironmentSelectOption;
// For "global" environments
environmentId: EnvironmentId;
@@ -105,6 +137,10 @@ export interface SessionV2 {
project_id: string;
launcher_id: string;
resource_class_id: number;
+ session_type: SessionType;
+ submission_id?: SubmissionId;
+ command_args?: string[] | null;
+ job_completed_at?: string | null;
}
export interface BuilderSelectorOption {
@@ -133,3 +169,8 @@ export interface SessionEnvironmentVariable {
name: string;
value: string;
}
+
+export type ImageStatus =
+ | "only-old-image-available"
+ | "no-available"
+ | "available";
diff --git a/client/src/features/sessionsV2/useLauncherEnvironmentReadiness.hook.ts b/client/src/features/sessionsV2/useLauncherEnvironmentReadiness.hook.ts
new file mode 100644
index 0000000000..82cc5c9361
--- /dev/null
+++ b/client/src/features/sessionsV2/useLauncherEnvironmentReadiness.hook.ts
@@ -0,0 +1,150 @@
+/*!
+ * 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 { skipToken } from "@reduxjs/toolkit/query";
+import { useContext } from "react";
+
+import { ImageStatus } from "~/features/sessionsV2/sessionsV2.types";
+import AppContext from "~/utils/context/appContext";
+import { DEFAULT_APP_PARAMS } from "~/utils/context/appParams.constants";
+import type { Build, SessionLauncher } from "./api/sessionLaunchersV2.api";
+import { useGetEnvironmentsByEnvironmentIdBuildsQuery as useGetBuildsQuery } from "./api/sessionLaunchersV2.api";
+import { useGetSessionsImagesQuery } from "./api/sessionsV2.api";
+import { getLauncherEnvironmentFlags } from "./launcherEnvironment.utils";
+
+interface UseLauncherEnvironmentReadinessArgs {
+ launcher?: SessionLauncher;
+ builds?: Build[];
+ lastBuild?: Build;
+}
+
+const DEFAULT_ENVIRONMENT_FLAGS = {
+ isCustomImageEnvironment: false,
+ isCodeEnvironment: false,
+ isGlobalEnvironment: false,
+};
+
+function getImageStatus({
+ hasCustomImageAccessible,
+ isGlobalEnvironment,
+ lastBuild,
+ useOldImage,
+}: {
+ hasCustomImageAccessible: boolean;
+ isGlobalEnvironment: boolean;
+ lastBuild?: Build;
+ useOldImage: boolean;
+}): ImageStatus {
+ if (useOldImage) {
+ return "only-old-image-available";
+ }
+ if (
+ isGlobalEnvironment ||
+ hasCustomImageAccessible ||
+ lastBuild?.status === "succeeded"
+ ) {
+ return "available";
+ }
+ return "no-available";
+}
+
+export default function useLauncherEnvironmentReadiness({
+ launcher,
+ builds: buildsProp,
+ lastBuild: lastBuildProp,
+}: UseLauncherEnvironmentReadinessArgs) {
+ const environment = launcher?.environment;
+ const { params } = useContext(AppContext);
+ const imageBuildersEnabled =
+ params?.IMAGE_BUILDERS_ENABLED ?? DEFAULT_APP_PARAMS.IMAGE_BUILDERS_ENABLED;
+
+ const { isCustomImageEnvironment, isCodeEnvironment, isGlobalEnvironment } =
+ launcher
+ ? getLauncherEnvironmentFlags(launcher)
+ : DEFAULT_ENVIRONMENT_FLAGS;
+
+ // custom + build cases
+ const shouldFetchBuilds =
+ launcher != null &&
+ imageBuildersEnabled &&
+ isCodeEnvironment &&
+ buildsProp == null;
+
+ const { data: fetchedBuilds, isLoading: isLoadingBuilds } = useGetBuildsQuery(
+ shouldFetchBuilds && environment
+ ? { environmentId: environment.id }
+ : skipToken,
+ );
+ const builds = buildsProp ?? fetchedBuilds;
+ const lastBuild = lastBuildProp ?? builds?.at(0);
+ const isBuildInProgress =
+ isCodeEnvironment && lastBuild?.status === "in_progress";
+ const lastSuccessfulBuild = builds?.find(
+ (build) => build.status === "succeeded" && build.id !== lastBuild?.id,
+ );
+ const hasSuccessfulBuild = Boolean(
+ lastBuild?.status === "succeeded" || lastSuccessfulBuild,
+ );
+ const useOldImage =
+ isCodeEnvironment &&
+ lastBuild?.status !== "succeeded" &&
+ !!lastSuccessfulBuild;
+
+ const containerImageUrl = environment?.container_image;
+
+ const shouldFetchContainerImage = containerImageUrl != null;
+ const { data: containerImage, isLoading: isLoadingContainerImage } =
+ useGetSessionsImagesQuery(
+ shouldFetchContainerImage ? { imageUrl: containerImageUrl } : skipToken,
+ );
+
+ const hasCustomImageAccessible = containerImage?.accessible === true;
+ const hasValidImage =
+ isGlobalEnvironment || hasSuccessfulBuild || hasCustomImageAccessible;
+
+ const imageStatus = getImageStatus({
+ hasCustomImageAccessible,
+ isGlobalEnvironment,
+ lastBuild,
+ useOldImage,
+ });
+
+ const forceLaunch =
+ isCustomImageEnvironment &&
+ !isLoadingContainerImage &&
+ !containerImage?.accessible;
+
+ return {
+ builds,
+ containerImage,
+ forceLaunch,
+ hasSuccessfulBuild,
+ hasValidImage,
+ isBuildInProgress,
+ isCodeEnvironment,
+ isCustomImageEnvironment,
+ isGlobalEnvironment,
+ isLoadingBuilds: shouldFetchBuilds && isLoadingBuilds,
+ isLoadingContainerImage:
+ shouldFetchContainerImage && isLoadingContainerImage,
+ lastBuild,
+ lastSuccessfulBuild,
+ useOldImage,
+ imageStatus,
+ };
+}
diff --git a/tests/cypress/e2e/projectV2.spec.ts b/tests/cypress/e2e/projectV2.spec.ts
index 4363433ab7..49ae901351 100644
--- a/tests/cypress/e2e/projectV2.spec.ts
+++ b/tests/cypress/e2e/projectV2.spec.ts
@@ -613,7 +613,7 @@ describe("Editor cannot maintain members", () => {
cy.contains("test 2 v2-project").should("be.visible");
cy.wait("@listProjectV2Members");
cy.wait("@getDataServicesUser");
- cy.getDataCy("add-session-launcher").should("be.visible");
+ cy.getDataCy("add-launcher").should("be.visible");
cy.getDataCy("add-data-connector").should("be.visible");
cy.getDataCy("add-code-repository").should("be.visible");
});
@@ -668,7 +668,7 @@ describe("Viewer cannot edit project", () => {
cy.contains("test 2 v2-project").should("be.visible");
cy.wait("@listProjectV2Members");
cy.wait("@getDataServicesUser");
- cy.getDataCy("add-session-launcher").should("not.exist");
+ cy.getDataCy("add-launcher").should("not.exist");
cy.getDataCy("add-data-connector").should("not.exist");
cy.getDataCy("add-code-repository").should("not.exist");
});
diff --git a/tests/cypress/e2e/projectV2LauncherActions.spec.ts b/tests/cypress/e2e/projectV2LauncherActions.spec.ts
new file mode 100644
index 0000000000..e6ea809c4e
--- /dev/null
+++ b/tests/cypress/e2e/projectV2LauncherActions.spec.ts
@@ -0,0 +1,430 @@
+/*!
+ * 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 fixtures from "../support/renkulab-fixtures";
+
+const PROJECT_PATH = "/p/user1-uuid/test-2-v2-project";
+
+function setupProjectSessionsPage({
+ launchersFixture = "projectV2/session-launchers.json",
+ permissionsFixture,
+}: {
+ launchersFixture?: string;
+ permissionsFixture?: string;
+} = {}) {
+ fixtures
+ .config()
+ .versions()
+ .userTest()
+ .dataServicesUser({
+ response: {
+ id: "user1-uuid",
+ username: "user-1",
+ email: "user1@email.com",
+ },
+ })
+ .projects()
+ .readGroupV2Namespace({ groupSlug: "user1-uuid" })
+ .landingUserProjects()
+ .readProjectV2()
+ .readProjectV2WithoutDocumentation()
+ .getStorageSchema({ fixture: "cloudStorage/storage-schema-s3.json" })
+ .listProjectV2Members()
+ .sessionLaunchers({ fixture: launchersFixture })
+ .sessionServersEmptyV2()
+ .sessionImage()
+ .resourcePoolsTest()
+ .getResourceClass()
+ .getRepositoryMetadata({
+ repositoryUrl: "https://domain.name/repo1.git",
+ })
+ .sessionSecretSlots({
+ fixture: "projectV2SessionSecrets/empty_list.json",
+ })
+ .sessionSecrets({
+ fixture: "projectV2SessionSecrets/empty_list.json",
+ });
+
+ if (permissionsFixture) {
+ fixtures.getProjectV2Permissions({
+ fixture: permissionsFixture,
+ });
+ }
+}
+
+function visitProjectSessions() {
+ cy.visit(PROJECT_PATH);
+ cy.wait("@readProjectV2WithoutDocumentation");
+ cy.wait("@sessionLaunchers");
+}
+
+function withinFirstSessionLauncher(callback: () => void) {
+ cy.getDataCy("session-launcher-item").first().within(callback);
+}
+
+function assertLaunchButtonShowsText(text: string) {
+ cy.getDataCy("start-session-button").should("contain.text", text);
+}
+
+function assertLaunchButtonShowsLaunch() {
+ assertLaunchButtonShowsText("Launch");
+}
+
+function openDropdownMenu() {
+ cy.getDataCy("button-with-menu-dropdown").click();
+}
+
+function assertDropdownHasItems() {
+ cy.get(".dropdown-menu.show .dropdown-item").should("have.length.gte", 1);
+}
+
+function openDropdownAndAssertNotEmpty() {
+ openDropdownMenu();
+ assertDropdownHasItems();
+}
+
+function assertLaunchButtonDisabled() {
+ cy.getDataCy("start-session-button").should(
+ "have.attr",
+ "aria-disabled",
+ "true",
+ );
+}
+
+function setupRunningSessionOnLauncher(launcherId: string) {
+ cy.fixture("sessions/sessionsV2.json").then((sessions) => {
+ cy.intercept("GET", "/api/data/sessions*", {
+ body: [
+ {
+ ...sessions[0],
+ launcher_id: launcherId,
+ },
+ ],
+ }).as("getSessionsV2");
+ cy.reload();
+ cy.wait("@sessionLaunchers");
+ cy.wait("@getSessionsV2");
+ });
+}
+
+function setupBuildLauncherIntercept() {
+ cy.fixture("projectV2/session-launchers.json").then((launchers) => {
+ const launcher = {
+ ...launchers[0],
+ environment: {
+ ...launchers[0].environment,
+ environment_image_source: "build",
+ },
+ };
+ cy.intercept("GET", "/api/data/projects/*/session_launchers", {
+ body: [launcher],
+ }).as("sessionLaunchers");
+ cy.intercept(
+ "GET",
+ `/api/data/environments/${launcher.environment.id}/builds`,
+ {
+ body: [
+ {
+ id: "build-in-progress",
+ status: "in_progress",
+ created_at: "2024-05-23T09:59:59Z",
+ },
+ ],
+ },
+ ).as("environmentBuilds");
+ });
+}
+
+function clickDropdownMenuIfPresent() {
+ cy.getDataCy("button-with-menu-dropdown").then(($toggle) => {
+ if ($toggle.length > 0) {
+ cy.wrap($toggle).click();
+ assertDropdownHasItems();
+ }
+ });
+}
+
+function clickSubmitJobButton() {
+ cy.getDataCy("submit-job-button").should("contain.text", "Submit").click();
+}
+
+function assertSubmitJobButtonEnabled() {
+ cy.getDataCy("submit-job-button").should("not.be.disabled");
+}
+
+function openFirstLauncherPanel() {
+ cy.getDataCy("session-launcher-item").first().click();
+}
+
+function setupOldImageBuildIntercept() {
+ cy.fixture("projectV2/session-launchers.json").then((launchers) => {
+ const launcher = {
+ ...launchers[0],
+ environment: {
+ ...launchers[0].environment,
+ environment_image_source: "build",
+ },
+ };
+ cy.intercept("GET", "/api/data/projects/*/session_launchers", {
+ body: [launcher],
+ }).as("sessionLaunchers");
+ cy.intercept(
+ "GET",
+ `/api/data/environments/${launcher.environment.id}/builds`,
+ {
+ body: [
+ {
+ id: "build-in-progress",
+ status: "in_progress",
+ created_at: "2024-05-23T09:59:59Z",
+ },
+ {
+ id: "build-old-success",
+ status: "succeeded",
+ created_at: "2024-05-22T09:59:59Z",
+ result: { completed_at: "2024-05-22T10:59:59Z" },
+ },
+ ],
+ },
+ ).as("environmentBuilds");
+ });
+}
+
+function setupRunningJob() {
+ cy.intercept("GET", "/api/data/sessions*", {
+ body: [
+ {
+ name: "job-run-1",
+ project_id: "01HYJE5FR1JV4CWFMBFJQFQ4RM",
+ launcher_id: "01HYJE99XEKWNKPYN8WRB6QA9Z",
+ session_type: "non-interactive",
+ status: { state: "running" },
+ },
+ ],
+ }).as("getSessionsV2");
+ cy.reload();
+ cy.wait("@sessionLaunchers");
+ cy.wait("@getSessionsV2");
+}
+
+describe("launcher card actions", () => {
+ describe("session launcher", () => {
+ beforeEach(() => {
+ setupProjectSessionsPage();
+ visitProjectSessions();
+ });
+
+ it("shows launch on card", () => {
+ withinFirstSessionLauncher(assertLaunchButtonShowsLaunch);
+ });
+
+ it("shows custom launch in dropdown", () => {
+ withinFirstSessionLauncher(openDropdownMenu);
+ cy.getDataCy("start-custom-session-button").should(
+ "contain.text",
+ "Custom launch",
+ );
+ });
+
+ it("never shows an empty dropdown menu", () => {
+ withinFirstSessionLauncher(openDropdownAndAssertNotEmpty);
+ });
+
+ it("disables launch when a session is already running", () => {
+ setupRunningSessionOnLauncher("01HYJE99XEKWNKPYN8WRB6QA8Z");
+ withinFirstSessionLauncher(assertLaunchButtonDisabled);
+ });
+
+ it("shows launch when build is in progress but container image is accessible", () => {
+ fixtures.getProjectV2Permissions();
+ setupBuildLauncherIntercept();
+
+ cy.intercept("GET", "/api/data/sessions/images?image_url=*", {
+ body: { accessible: true },
+ }).as("sessionImage");
+
+ withinFirstSessionLauncher(assertLaunchButtonShowsLaunch);
+ });
+
+ it("does not show an empty dropdown when launch is unavailable during build", () => {
+ setupBuildLauncherIntercept();
+
+ cy.intercept("GET", "/api/data/sessions/images?image_url=*", {
+ body: { accessible: false },
+ }).as("sessionImage");
+
+ withinFirstSessionLauncher(clickDropdownMenuIfPresent);
+ });
+ });
+
+ describe("job launcher", () => {
+ beforeEach(() => {
+ setupProjectSessionsPage({
+ launchersFixture: "projectV2/session-launchers-job.json",
+ permissionsFixture: "projectV2/projectV2-permissions-editor.json",
+ });
+ visitProjectSessions();
+ cy.wait("@getProjectV2Permissions");
+ });
+
+ it("shows submit button on card (stub — no modal)", () => {
+ withinFirstSessionLauncher(clickSubmitJobButton);
+ cy.getDataCy("submit-job-modal").should("not.exist");
+ });
+
+ it("allows submit when a job is already running", () => {
+ setupRunningJob();
+ withinFirstSessionLauncher(assertSubmitJobButtonEnabled);
+ });
+ });
+});
+
+describe("read-only user on job launcher", () => {
+ beforeEach(() => {
+ setupProjectSessionsPage({
+ launchersFixture: "projectV2/session-launchers-job.json",
+ permissionsFixture: "projectV2/projectV2-permissions-viewer.json",
+ });
+ visitProjectSessions();
+ cy.wait("@sessionServersEmptyV2");
+ cy.wait("@getProjectV2Permissions");
+ });
+
+ it("shows submit only without management dropdown", () => {
+ cy.getDataCy("session-launcher-item")
+ .first()
+ .within(() => {
+ cy.getDataCy("submit-job-button").should("be.visible");
+ cy.getDataCy("button-with-menu-dropdown").should("not.exist");
+ });
+ });
+});
+
+describe("launcher panel actions", () => {
+ it("shows force launch on main button for inaccessible external image", () => {
+ fixtures
+ .config()
+ .versions()
+ .userTest()
+ .dataServicesUser({
+ response: {
+ id: "user1-uuid",
+ username: "user-1",
+ email: "user1@email.com",
+ },
+ })
+ .projects()
+ .readGroupV2Namespace({ groupSlug: "user1-uuid" })
+ .landingUserProjects()
+ .readProjectV2()
+ .readProjectV2WithoutDocumentation()
+ .getStorageSchema({ fixture: "cloudStorage/storage-schema-s3.json" })
+ .listProjectV2Members()
+ .sessionServersEmptyV2()
+ .resourcePoolsTest()
+ .getResourceClass()
+ .getRepositoryMetadata({
+ repositoryUrl: "https://domain.name/repo1.git",
+ })
+ .sessionSecretSlots({
+ fixture: "projectV2SessionSecrets/empty_list.json",
+ })
+ .sessionSecrets({
+ fixture: "projectV2SessionSecrets/empty_list.json",
+ });
+
+ cy.fixture("projectV2/session-launchers.json").then((launchers) => {
+ const launcher = {
+ ...launchers[0],
+ environment: {
+ ...launchers[0].environment,
+ environment_image_source: "image",
+ },
+ };
+ cy.intercept("GET", "/api/data/projects/*/session_launchers", {
+ body: [launcher],
+ }).as("sessionLaunchers");
+ });
+
+ cy.intercept("GET", "/api/data/sessions/images?image_url=*", {
+ body: { accessible: false },
+ }).as("sessionImage");
+
+ cy.visit(PROJECT_PATH);
+ cy.wait("@readProjectV2WithoutDocumentation");
+ cy.wait("@sessionLaunchers");
+ cy.wait("@sessionImage");
+
+ cy.getDataCy("session-launcher-item").first().click();
+ cy.getDataCy("start-session-button").should("contain.text", "Force launch");
+ });
+
+ it("shows launch in panel when build is in progress but container image is accessible", () => {
+ fixtures.getProjectV2Permissions();
+ setupProjectSessionsPage();
+ setupBuildLauncherIntercept();
+
+ cy.intercept("GET", "/api/data/sessions/images?image_url=*", {
+ body: { accessible: true },
+ }).as("sessionImage");
+
+ visitProjectSessions();
+ openFirstLauncherPanel();
+ cy.getDataCy("start-session-button").should("contain.text", "Launch");
+ });
+
+ it("shows older image tooltip on launch button in panel", () => {
+ fixtures.getProjectV2Permissions();
+ setupProjectSessionsPage();
+ setupOldImageBuildIntercept();
+
+ cy.intercept("GET", "/api/data/sessions/images?image_url=*", {
+ body: { accessible: true },
+ }).as("sessionImage");
+
+ visitProjectSessions();
+ openFirstLauncherPanel();
+ cy.getDataCy("start-session-button").trigger("mouseenter");
+ cy.get(".tooltip").should("contain.text", "older image");
+ });
+
+ it("shows submit only in read-only job launcher panel", () => {
+ setupProjectSessionsPage({
+ launchersFixture: "projectV2/session-launchers-job.json",
+ permissionsFixture: "projectV2/projectV2-permissions-viewer.json",
+ });
+ visitProjectSessions();
+ cy.wait("@sessionServersEmptyV2");
+ cy.wait("@getProjectV2Permissions");
+
+ openFirstLauncherPanel();
+ cy.getDataCy("submit-job-button").should("be.visible");
+ cy.getDataCy("button-with-menu-dropdown").should("not.exist");
+ });
+
+ it("shows submit only in job launcher panel", () => {
+ setupProjectSessionsPage({
+ launchersFixture: "projectV2/session-launchers-job.json",
+ });
+ visitProjectSessions();
+
+ cy.getDataCy("session-launcher-item").first().click();
+ cy.getDataCy("submit-job-button").should("be.visible");
+ cy.getDataCy("button-with-menu-dropdown").should("not.exist");
+ });
+});
diff --git a/tests/cypress/e2e/projectV2Session.spec.ts b/tests/cypress/e2e/projectV2Session.spec.ts
index 22bf0c2539..cdad8b33b7 100644
--- a/tests/cypress/e2e/projectV2Session.spec.ts
+++ b/tests/cypress/e2e/projectV2Session.spec.ts
@@ -72,7 +72,7 @@ describe("launch sessions with data connectors", () => {
});
cy.visit("/p/user1-uuid/test-2-v2-project");
- cy.wait("@readProjectV2");
+ cy.wait("@readProjectV2WithoutDocumentation");
cy.wait("@sessionServersEmptyV2");
cy.wait("@sessionLaunchers");
cy.wait("@listProjectDataConnectors");
@@ -1083,7 +1083,7 @@ describe("view autostart link", () => {
fixture: "projectV2SessionSecrets/empty_list.json",
});
cy.visit("/p/user1-uuid/test-2-v2-project");
- cy.wait("@readProjectV2");
+ cy.wait("@readProjectV2WithoutDocumentation");
});
it("use autostart link", () => {
@@ -1092,7 +1092,7 @@ describe("view autostart link", () => {
});
cy.visit("/p/user1-uuid/test-2-v2-project");
- cy.wait("@readProjectV2");
+ cy.wait("@readProjectV2WithoutDocumentation");
cy.wait("@sessionServersEmptyV2");
cy.wait("@sessionLaunchers");
cy.wait("@listProjectDataConnectors");
diff --git a/tests/cypress/e2e/projectV2setup.spec.ts b/tests/cypress/e2e/projectV2setup.spec.ts
index 900ed10b06..6e3a8f31a7 100644
--- a/tests/cypress/e2e/projectV2setup.spec.ts
+++ b/tests/cypress/e2e/projectV2setup.spec.ts
@@ -101,7 +101,7 @@ describe("Set up project components", () => {
cy.wait("@getSessionsV2");
cy.wait("@sessionLaunchers");
// ADD SESSION CUSTOM IMAGE
- cy.getDataCy("add-session-launcher").click();
+ cy.openSessionLauncherCreateFlow();
fixtures.sessionLaunchers({
fixture: "projectV2/session-launchers.json",
@@ -113,9 +113,9 @@ describe("Set up project components", () => {
.clear()
.type(customImage, { delay: 0 })
.should("have.value", customImage);
- cy.getDataCy("next-session-button").click();
+ cy.getDataCy("next-launcher-button").click();
cy.getDataCy("launcher-name-input").type("Session-custom");
- cy.getDataCy("add-session-button").click();
+ cy.getDataCy("add-launcher-button").click();
cy.wait("@newLauncher");
cy.wait("@session-launchers-custom");
cy.getDataCy("close-cancel-button").click();
@@ -152,15 +152,15 @@ describe("Set up project components", () => {
cy.go("back");
// ADD SESSION EXISTING ENVIRONMENT
- cy.getDataCy("add-session-launcher").click();
+ cy.openSessionLauncherCreateFlow();
fixtures.sessionLaunchers({
fixture: "projectV2/session-launchers-global.json",
name: "session-launchers-global",
});
cy.getDataCy("environment-kind-global").click();
cy.getDataCy("global-environment-item").first().click();
- cy.getDataCy("next-session-button").click();
- cy.getDataCy("add-session-button").click();
+ cy.getDataCy("next-launcher-button").click();
+ cy.getDataCy("add-launcher-button").click();
cy.wait("@newLauncher");
cy.wait("@session-launchers-global");
@@ -169,6 +169,31 @@ describe("Set up project components", () => {
cy.getDataCy("session-name").should("contain.text", "Jupyter Notebook");
cy.getDataCy("start-session-button").should("contain.text", "Launch");
});
+
+ // ADD JOB CUSTOM IMAGE
+ cy.openJobLauncherCreateFlow();
+ fixtures.sessionLaunchers({
+ fixture: "projectV2/session-launchers-job.json",
+ name: "session-launchers-job",
+ });
+ const jobImage = "renku/renkulab-py:latest";
+ cy.getDataCy("environment-kind-custom").click();
+ cy.getDataCy("custom-image-input")
+ .clear()
+ .type(jobImage, { delay: 0 })
+ .should("have.value", jobImage);
+ cy.getDataCy("next-launcher-button").click();
+ cy.getDataCy("launcher-name-input").type("Job-custom");
+ cy.getDataCy("add-launcher-button").click();
+ cy.wait("@newLauncher");
+ cy.wait("@session-launchers-job");
+ cy.getDataCy("close-cancel-button").click();
+ cy.getDataCy("session-launcher-item")
+ .contains("Job-custom")
+ .parents("[data-cy='session-launcher-item']")
+ .within(() => {
+ cy.getDataCy("submit-job-button").should("contain.text", "Submit");
+ });
});
});
diff --git a/tests/cypress/fixtures/projectV2/session-launchers-global.json b/tests/cypress/fixtures/projectV2/session-launchers-global.json
index 38b38bc4e7..ae66bfba3f 100644
--- a/tests/cypress/fixtures/projectV2/session-launchers-global.json
+++ b/tests/cypress/fixtures/projectV2/session-launchers-global.json
@@ -17,6 +17,7 @@
"mount_directory": "",
"port": 8080,
"environment_kind": "GLOBAL"
- }
+ },
+ "launcher_type": "interactive"
}
]
diff --git a/tests/cypress/fixtures/projectV2/session-launchers-job.json b/tests/cypress/fixtures/projectV2/session-launchers-job.json
new file mode 100644
index 0000000000..f048b9b008
--- /dev/null
+++ b/tests/cypress/fixtures/projectV2/session-launchers-job.json
@@ -0,0 +1,24 @@
+[
+ {
+ "id": "01HYJE99XEKWNKPYN8WRB6QA9Z",
+ "project_id": "01HYJE5FR1JV4CWFMBFJQFQ4RM",
+ "name": "Job-custom",
+ "creation_date": "2024-05-23T09:59:59Z",
+ "environment": {
+ "id": "01JBXS0N2RESJP9W7ZWGZV3A2L",
+ "name": "Job-custom",
+ "creation_date": "2024-11-05T09:03:12.977947Z",
+ "container_image": "renku/renkulab-py:latest",
+ "default_url": "/",
+ "uid": 1000,
+ "gid": 1000,
+ "working_directory": "/",
+ "mount_directory": "",
+ "port": 8080,
+ "environment_kind": "CUSTOM",
+ "environment_image_source": "image"
+ },
+ "resource_class_id": 2,
+ "launcher_type": "non-interactive"
+ }
+]
diff --git a/tests/cypress/fixtures/projectV2/session-launchers-with-renkulab-gitlab.json b/tests/cypress/fixtures/projectV2/session-launchers-with-renkulab-gitlab.json
index 5ecf0bfa3f..24f17925d1 100644
--- a/tests/cypress/fixtures/projectV2/session-launchers-with-renkulab-gitlab.json
+++ b/tests/cypress/fixtures/projectV2/session-launchers-with-renkulab-gitlab.json
@@ -27,7 +27,8 @@
"name": "VAR_WITH_LONGER_NAME",
"value": "value2"
}
- ]
+ ],
+ "launcher_type": "interactive"
},
{
"id": "01HYJE99XEKWNKPYN8WRB6QA8Y",
@@ -47,6 +48,7 @@
"port": 8080,
"environment_kind": "CUSTOM"
},
- "resource_class_id": 2
+ "resource_class_id": 2,
+ "launcher_type": "interactive"
}
]
diff --git a/tests/cypress/fixtures/projectV2/session-launchers-without-env-vars.json b/tests/cypress/fixtures/projectV2/session-launchers-without-env-vars.json
index 0460a4d5ea..925fe32014 100644
--- a/tests/cypress/fixtures/projectV2/session-launchers-without-env-vars.json
+++ b/tests/cypress/fixtures/projectV2/session-launchers-without-env-vars.json
@@ -18,6 +18,7 @@
"environment_kind": "CUSTOM"
},
"resource_class_id": 2,
- "env_variables": []
+ "env_variables": [],
+ "launcher_type": "interactive"
}
]
diff --git a/tests/cypress/fixtures/projectV2/session-launchers.json b/tests/cypress/fixtures/projectV2/session-launchers.json
index bef84a20e4..264f5c2a7a 100644
--- a/tests/cypress/fixtures/projectV2/session-launchers.json
+++ b/tests/cypress/fixtures/projectV2/session-launchers.json
@@ -27,6 +27,7 @@
"name": "VAR_WITH_LONGER_NAME",
"value": "value2"
}
- ]
+ ],
+ "launcher_type": "interactive"
}
]
diff --git a/tests/cypress/support/commands/sessions.ts b/tests/cypress/support/commands/sessions.ts
new file mode 100644
index 0000000000..3eabc7a7ad
--- /dev/null
+++ b/tests/cypress/support/commands/sessions.ts
@@ -0,0 +1,67 @@
+/*!
+ * 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.
+ */
+
+function openLogs() {
+ cy.getDataCy("session-container")
+ .find(".sessionsButton")
+ .first()
+ .find("[data-cy='more-menu']")
+ .click();
+ cy.getDataCy("session-log-button").filter(":visible").click();
+}
+
+function openSession() {
+ cy.getDataCy("session-container")
+ .find("[data-cy='open-session']")
+ .first()
+ .click();
+}
+
+function openSessionLauncherCreateFlow() {
+ cy.getDataCy("add-launcher").click();
+ cy.getDataCy("launcher-type-selector-modal").should("be.visible");
+ cy.getDataCy("launcher-option-session").click();
+}
+
+function openJobLauncherCreateFlow() {
+ cy.getDataCy("add-launcher").click();
+ cy.getDataCy("launcher-type-selector-modal").should("be.visible");
+ cy.getDataCy("launcher-option-job").click();
+}
+
+export default function registerSessionsCommands() {
+ Cypress.Commands.add("openLogs", openLogs);
+ Cypress.Commands.add("openSession", openSession);
+ Cypress.Commands.add(
+ "openSessionLauncherCreateFlow",
+ openSessionLauncherCreateFlow,
+ );
+ Cypress.Commands.add("openJobLauncherCreateFlow", openJobLauncherCreateFlow);
+}
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+ openLogs: typeof openLogs;
+ openSession: typeof openSession;
+ openSessionLauncherCreateFlow: typeof openSessionLauncherCreateFlow;
+ openJobLauncherCreateFlow: typeof openJobLauncherCreateFlow;
+ }
+ }
+}
diff --git a/tests/cypress/support/e2e.ts b/tests/cypress/support/e2e.ts
index 7e571d0619..6e90c066aa 100644
--- a/tests/cypress/support/e2e.ts
+++ b/tests/cypress/support/e2e.ts
@@ -19,9 +19,11 @@
import "cypress-file-upload";
import registerGeneralCommands from "./commands/general";
import registerReactSelectCommands from "./commands/react-select";
+import registerSessionsCommands from "./commands/sessions";
registerGeneralCommands();
registerReactSelectCommands();
+registerSessionsCommands();
Cypress.on("uncaught:exception", () => {
// returning false here prevents Cypress from failing the test
diff --git a/tests/cypress/support/renkulab-fixtures/projectV2.ts b/tests/cypress/support/renkulab-fixtures/projectV2.ts
index 27a0559d17..d99af3596a 100644
--- a/tests/cypress/support/renkulab-fixtures/projectV2.ts
+++ b/tests/cypress/support/renkulab-fixtures/projectV2.ts
@@ -437,7 +437,7 @@ export function ProjectV2(Parent: T) {
const response = { fixture };
cy.intercept(
"GET",
- `/api/data/namespaces/${namespace}/projects/${projectSlug}`,
+ `/api/data/namespaces/${namespace}/projects/${projectSlug}*`,
response,
).as(name);
return this;