diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index 141267c06f..9f560fde9d 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -288,11 +288,12 @@ export default defineConfig([ globalIgnores([ "**/*.css", "**/*.svg", + "src/features/cloudStorage/api/projectCloudStorage.generated-api.ts", "src/features/connectedServices/api/connectedServices.generated-api.ts", "src/features/dataConnectorsV2/api/data-connectors.api.ts", "src/features/dataConnectorsV2/api/doiResolver.generated-api.ts", "src/features/notifications/api/notifications.generated-api.ts", - "src/features/cloudStorage/api/projectCloudStorage.generated-api.ts", + "src/features/platform/api/platform.generated-api.ts", "src/features/projectsV2/api/projectV2.api.ts", "src/features/searchV2/api/searchV2Api.generated-api.ts", "src/features/sessionsV2/api/computeResources.generated-api.ts", diff --git a/client/package-lock.json b/client/package-lock.json index 1597ed2954..421132e31d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,8 +14,8 @@ "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.2.0", "@popperjs/core": "^2.11.8", - "@react-router/express": "^7.13.1", - "@react-router/node": "^7.13.1", + "@react-router/express": "^7.17.0", + "@react-router/node": "^7.17.0", "@reduxjs/toolkit": "^2.11.2", "@sentry/react": "^10.38.0", "@sentry/react-router": "^10.38.0", @@ -44,7 +44,7 @@ "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", - "react-router": "^7.13.1", + "react-router": "^7.17.0", "react-select": "^5.10.2", "react-toastify": "^11.0.5", "reactstrap": "^9.2.3", @@ -65,7 +65,7 @@ "@eslint/js": "~9.39.3", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@nabla/vite-plugin-eslint": "^3.0.0", - "@react-router/dev": "^7.13.1", + "@react-router/dev": "^7.17.0", "@rtk-query/codegen-openapi": "^2.2.0", "@storybook/addon-docs": "^10.2.15", "@storybook/react-vite": "^10.2.15", @@ -4010,9 +4010,9 @@ } }, "node_modules/@react-router/dev": { - "version": "7.13.1", - "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.13.1.tgz", - "integrity": "sha512-H+kEvbbOaWGaitOyL6CgqPsHqRUh66HuVRvIEaZEqdoAY/1xChdhmmq6ZumMHzcFHgHlfOcoXgNHlz6ZO4NWcg==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.17.0.tgz", + "integrity": "sha512-+ITMmv/1xUB+QF6ehCCOIVBNBDuKIEE+3JKCt+kTBvxDTk1s49qHbtCA4TzJYge41pNRGtFIiy5VAksLSVJheg==", "dev": true, "license": "MIT", "dependencies": { @@ -4023,7 +4023,7 @@ "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", - "@react-router/node": "7.13.1", + "@react-router/node": "7.17.0", "@remix-run/node-fetch-server": "^0.13.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", @@ -4052,12 +4052,12 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@react-router/serve": "^7.13.1", - "@vitejs/plugin-rsc": "~0.5.7", - "react-router": "^7.13.1", + "@react-router/serve": "^7.17.0", + "@vitejs/plugin-rsc": "~0.5.21", + "react-router": "^7.17.0", "react-server-dom-webpack": "^19.2.3", - "typescript": "^5.1.0", - "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", + "typescript": "^5.1.0 || ^6.0.0", + "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "peerDependenciesMeta": { @@ -4092,20 +4092,20 @@ } }, "node_modules/@react-router/express": { - "version": "7.13.1", - "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.13.1.tgz", - "integrity": "sha512-ujHom4LiEWsbnohNArwNT86QP3WRB5p+rY8AAll6s4gdrzgOXIy3FHDc3up5Lz8juUrZKh0d+B+PZa/IdDSK3A==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.17.0.tgz", + "integrity": "sha512-/onhRWlfCRTq0bI7t06d2KGRUr+Gy12+CHLZDaHkdtkHJxsUFPGlnfomBMQXQLIPW544I5ykKAY8Z2d/1J6+bQ==", "license": "MIT", "dependencies": { - "@react-router/node": "7.13.1" + "@react-router/node": "7.17.0" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { "express": "^4.17.1 || ^5", - "react-router": "7.13.1", - "typescript": "^5.1.0" + "react-router": "7.17.0", + "typescript": "^5.1.0 || ^6.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -4114,9 +4114,9 @@ } }, "node_modules/@react-router/node": { - "version": "7.13.1", - "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.13.1.tgz", - "integrity": "sha512-IWPPf+Q3nJ6q4bwyTf5leeGUfg8GAxSN1RKj5wp9SK915zKK+1u4TCOfOmr8hmC6IW1fcjKV0WChkM0HkReIiw==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.17.0.tgz", + "integrity": "sha512-RYR47qM9gJ8zV8Ntial5Rkgcst2YnwWXt0Ai34FezzkDK6AILpxpVatEzFEhNRwbSh6JO6iweY7XhfM4/K5dBA==", "license": "MIT", "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" @@ -4125,8 +4125,8 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react-router": "7.13.1", - "typescript": "^5.1.0" + "react-router": "7.17.0", + "typescript": "^5.1.0 || ^6.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -17946,9 +17946,9 @@ } }, "node_modules/react-router": { - "version": "7.13.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", - "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", diff --git a/client/package.json b/client/package.json index a0e8f1ec82..ac8515a639 100644 --- a/client/package.json +++ b/client/package.json @@ -59,8 +59,8 @@ "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.2.0", "@popperjs/core": "^2.11.8", - "@react-router/express": "^7.13.1", - "@react-router/node": "^7.13.1", + "@react-router/express": "^7.17.0", + "@react-router/node": "^7.17.0", "@reduxjs/toolkit": "^2.11.2", "@sentry/react": "^10.38.0", "@sentry/react-router": "^10.38.0", @@ -89,7 +89,7 @@ "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", - "react-router": "^7.13.1", + "react-router": "^7.17.0", "react-select": "^5.10.2", "react-toastify": "^11.0.5", "reactstrap": "^9.2.3", @@ -110,7 +110,7 @@ "@eslint/js": "~9.39.3", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@nabla/vite-plugin-eslint": "^3.0.0", - "@react-router/dev": "^7.13.1", + "@react-router/dev": "^7.17.0", "@rtk-query/codegen-openapi": "^2.2.0", "@storybook/addon-docs": "^10.2.15", "@storybook/react-vite": "^10.2.15", diff --git a/client/src/authentication/listeners.test.ts b/client/src/authentication/listeners.test.ts index 61d94ffd45..c5e4cca1c2 100644 --- a/client/src/authentication/listeners.test.ts +++ b/client/src/authentication/listeners.test.ts @@ -33,7 +33,6 @@ const location: Location = { hash: "", key: "default", state: undefined, - unstable_mask: undefined, }; const navigate = vi.fn(); diff --git a/client/src/components/TimeCaption.tsx b/client/src/components/TimeCaption.tsx index 1706aee39f..a9fb3e2abc 100644 --- a/client/src/components/TimeCaption.tsx +++ b/client/src/components/TimeCaption.tsx @@ -34,6 +34,7 @@ interface TimeCaptionProps { noCaption?: boolean; prefix?: ReactNode; suffix?: ReactNode; + format?: "long" | "short"; } export function TimeCaption({ @@ -43,13 +44,14 @@ export function TimeCaption({ noCaption, prefix, suffix, + format = "long", }: TimeCaptionProps) { const [now, setNow] = useState(DateTime.utc()); const datetime = datetime_ ? ensureDateTime(datetime_) : null; const durationStr = datetime != null && datetime.isValid - ? toHumanRelativeDuration({ datetime, now }) + ? toHumanRelativeDuration({ datetime, now, format }) : "at unknown time"; const className = noCaption ? className_ : cx("time-caption", className_); @@ -71,9 +73,9 @@ export function TimeCaption({ const refresh = Math.min( Math.max( duration.toMillis() / 10, - Duration.fromObject({ seconds: 5 }).toMillis(), + Duration.fromObject({ seconds: 5 }).toMillis() ), - Duration.fromObject({ minutes: 10 }).toMillis(), + Duration.fromObject({ minutes: 10 }).toMillis() ); /* eslint-enable spellcheck/spell-checker */ diff --git a/client/src/components/entities/entities.types.ts b/client/src/components/entities/entities.types.ts index 23f4c121f4..528017b550 100644 --- a/client/src/components/entities/entities.types.ts +++ b/client/src/components/entities/entities.types.ts @@ -1,4 +1,5 @@ export type EntityTypes = | "code-repository" | "data-connector" + | "job-launcher" | "session-launcher"; diff --git a/client/src/components/offcanvas/OffcanvasHeaderWithType.tsx b/client/src/components/offcanvas/OffcanvasHeaderWithType.tsx index 0454511b50..835b7cb27a 100644 --- a/client/src/components/offcanvas/OffcanvasHeaderWithType.tsx +++ b/client/src/components/offcanvas/OffcanvasHeaderWithType.tsx @@ -2,6 +2,7 @@ import cx from "classnames"; import { Database, FileCode, + Gear, PlayCircle, QuestionCircle, } from "react-bootstrap-icons"; @@ -29,6 +30,8 @@ export default function OffcanvasHeaderWithType({ ) : entityType === "session-launcher" ? ( + ) : entityType === "job-launcher" ? ( + ) : entityType === "code-repository" ? ( ) : ( @@ -38,12 +41,12 @@ export default function OffcanvasHeaderWithType({ const entityName = _entityName ? _entityName : entityType === "data-connector" - ? "Data connector" - : entityType === "session-launcher" - ? "Session launcher" - : entityType === "code-repository" - ? "Code repository" - : "Unknown"; + ? "Data connector" + : entityType === "session-launcher" + ? "Session launcher" + : entityType === "code-repository" + ? "Code repository" + : "Unknown"; return (
diff --git a/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx b/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx index d56bc897d8..03ce3abe41 100644 --- a/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx +++ b/client/src/features/admin/SessionEnvironmentAdvancedFields.tsx @@ -39,9 +39,9 @@ export default function SessionEnvironmentAdvancedFields({ const toggleIsOpen = useCallback( () => setIsAdvancedSettingsOpen( - (isAdvancedSettingOpen) => !isAdvancedSettingOpen, + (isAdvancedSettingOpen) => !isAdvancedSettingOpen ), - [], + [] ); return ( <> @@ -56,7 +56,7 @@ export default function SessionEnvironmentAdvancedFields({ "fw-bold", "gap-1", "p-0", - "w-100", + "w-100" )} type="button" onClick={toggleIsOpen} @@ -70,6 +70,7 @@ export default function SessionEnvironmentAdvancedFields({ control={control} errors={errors} + launcherCategory="session" /> diff --git a/client/src/features/dashboardV2/DashboardV2Sessions.tsx b/client/src/features/dashboardV2/DashboardV2Sessions.tsx index cad92358f8..db4a14ca91 100644 --- a/client/src/features/dashboardV2/DashboardV2Sessions.tsx +++ b/client/src/features/dashboardV2/DashboardV2Sessions.tsx @@ -22,6 +22,7 @@ import cx from "classnames"; import { generatePath, Link } from "react-router"; import { Col, ListGroup, Row } from "reactstrap"; +import { sessionLauncherKindToCategory } from "~/features/sessionsV2/session.utils.ts"; import RtkOrDataServicesError from "../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../components/Loader"; import { ABSOLUTE_ROUTES } from "../../routing/routes.constants"; @@ -112,10 +113,10 @@ interface DashboardSessionProps { function DashboardSession({ session }: DashboardSessionProps) { const { project_id: projectId, launcher_id: launcherId } = session; const { data: project } = useGetProjectsByProjectIdQuery( - projectId ? { projectId } : skipToken, + projectId ? { projectId } : skipToken ); const { data: launcher } = useGetProjectSessionLauncherQuery( - launcherId ? { launcherId } : skipToken, + launcherId ? { launcherId } : skipToken ); const projectUrl = project @@ -124,10 +125,10 @@ function DashboardSession({ session }: DashboardSessionProps) { slug: project.slug, }) : projectId - ? generatePath(ABSOLUTE_ROUTES.v2.projects.showById, { - id: projectId, - }) - : ABSOLUTE_ROUTES.v2.index; + ? generatePath(ABSOLUTE_ROUTES.v2.projects.showById, { + id: projectId, + }) + : ABSOLUTE_ROUTES.v2.index; const showSessionUrl = project ? generatePath(ABSOLUTE_ROUTES.v2.projects.show.sessions.show, { namespace: project.namespace, @@ -136,7 +137,8 @@ function DashboardSession({ session }: DashboardSessionProps) { }) : ABSOLUTE_ROUTES.v2.index; - const sessionStyles = getSessionStatusStyles(session); + const launcherCategory = sessionLauncherKindToCategory(session.session_type); + const sessionStyles = getSessionStatusStyles(session, launcherCategory); const state = session.status.state; return ( @@ -151,7 +153,7 @@ function DashboardSession({ session }: DashboardSessionProps) { "gap-3", "link-primary", "text-body", - "text-decoration-none", + "text-decoration-none" )} to={{ pathname: projectUrl }} > @@ -165,7 +167,7 @@ function DashboardSession({ session }: DashboardSessionProps) { "cursor-pointer", "d-inline-block", "link-primary", - "text-body", + "text-body" )} data-cy="list-session-link" > @@ -176,7 +178,7 @@ function DashboardSession({ session }: DashboardSessionProps) { {launcher?.environment?.name} ) : ( - (projectId ?? "Unknown") + projectId ?? "Unknown" )} @@ -195,7 +197,7 @@ function DashboardSession({ session }: DashboardSessionProps) { "mt-2", "d-block", "d-sm-flex", - "gap-5", + "gap-5" )} xs={12} > diff --git a/client/src/features/logsDisplay/LogsModal.tsx b/client/src/features/logsDisplay/LogsModal.tsx index 196515147b..bccdd99a65 100644 --- a/client/src/features/logsDisplay/LogsModal.tsx +++ b/client/src/features/logsDisplay/LogsModal.tsx @@ -100,10 +100,13 @@ export default function LogsModal({ "fs-4", "fst-italic", "mb-0", - getSessionStatusStyles({ - status: { state: sessionState }, - image: "url", - })["textColorCard"], + getSessionStatusStyles( + { + status: { state: sessionState }, + image: "url", + }, + "session" + )["textColorCard"] )} > Session status: {sessionState} @@ -217,7 +220,7 @@ function TabbedLogs({ data, defaultTab }: TabbedLogsProps) { }, [data, defaultTab]); const [activeTab, setActiveTab] = useState( - sortedLogs.at(0)?.tab ?? "", + sortedLogs.at(0)?.tab ?? "" ); const preRef = useRef(null); @@ -301,7 +304,7 @@ function ModalFooterButtons({ const [isDownloading, triggerDownload] = useDownloadLogs( name, refetch, - downloadQueryTrigger, + downloadQueryTrigger ); const canDownload = !isFetching && @@ -350,7 +353,7 @@ function ModalFooterButtons({ function useDownloadLogs( name: string, refetch: LogsQuery["refetch"], - downloadQueryTrigger: DownloadLogsLazyQueryTrigger | undefined | null, + downloadQueryTrigger: DownloadLogsLazyQueryTrigger | undefined | null ): [boolean, () => void] { const [isDownloading, setIsDownloading] = useState(false); diff --git a/client/src/features/platform/api/platform.generated-api.ts b/client/src/features/platform/api/platform.generated-api.ts index c912d41e3f..2fc1baec4b 100644 --- a/client/src/features/platform/api/platform.generated-api.ts +++ b/client/src/features/platform/api/platform.generated-api.ts @@ -21,6 +21,25 @@ const injectedRtkApi = api.injectEndpoints({ }, }), }), + getPlatformAuthorizationConfig: build.query< + GetPlatformAuthorizationConfigApiResponse, + GetPlatformAuthorizationConfigApiArg + >({ + query: () => ({ url: `/platform/authorization_config` }), + }), + patchPlatformAuthorizationConfig: build.mutation< + PatchPlatformAuthorizationConfigApiResponse, + PatchPlatformAuthorizationConfigApiArg + >({ + query: (queryArg) => ({ + url: `/platform/authorization_config`, + method: "PATCH", + body: queryArg.authzConfigPatch, + headers: { + "If-Match": queryArg["If-Match"], + }, + }), + }), getPlatformRedirects: build.query< GetPlatformRedirectsApiResponse, GetPlatformRedirectsApiArg @@ -89,6 +108,16 @@ export type PatchPlatformConfigApiArg = { "If-Match": ETag; platformConfigPatch: PlatformConfigPatch; }; +export type GetPlatformAuthorizationConfigApiResponse = + /** status 200 The authorization configuration */ AuthzConfig; +export type GetPlatformAuthorizationConfigApiArg = void; +export type PatchPlatformAuthorizationConfigApiResponse = + /** status 200 The updated platform configuration */ AuthzConfig; +export type PatchPlatformAuthorizationConfigApiArg = { + /** If-Match header, for avoiding mid-air collisions */ + "If-Match": ETag; + authzConfigPatch: AuthzConfigPatch; +}; export type GetPlatformRedirectsApiResponse = /** status 200 A list of redirect plans */ UrlRedirectPlanList; export type GetPlatformRedirectsApiArg = { @@ -140,6 +169,16 @@ export type ErrorResponse = { export type PlatformConfigPatch = { incident_banner?: IncidentBanner; }; +export type AuthzFlag = "admins_only" | "registered_users"; +export type AuthzConfig = { + etag: ETag; + create_projects: AuthzFlag; + create_groups: AuthzFlag; +}; +export type AuthzConfigPatch = { + create_projects?: AuthzFlag; + create_groups?: AuthzFlag; +}; export type SourceUrl = string; export type TargetUrl = string; export type UrlRedirectPlan = { @@ -165,6 +204,8 @@ export type UrlRedirectPlanPatch = { export const { useGetPlatformConfigQuery, usePatchPlatformConfigMutation, + useGetPlatformAuthorizationConfigQuery, + usePatchPlatformAuthorizationConfigMutation, useGetPlatformRedirectsQuery, usePostPlatformRedirectsMutation, useGetPlatformRedirectsBySourceUrlQuery, diff --git a/client/src/features/platform/api/platform.openapi.json b/client/src/features/platform/api/platform.openapi.json index 4c71f48117..c5a43f2cec 100644 --- a/client/src/features/platform/api/platform.openapi.json +++ b/client/src/features/platform/api/platform.openapi.json @@ -67,6 +67,62 @@ "tags": ["platform"] } }, + "/platform/authorization_config": { + "get": { + "summary": "Get the current authorization configuration for Renku", + "responses": { + "200": { + "description": "The authorization configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthzConfig" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["platform"] + }, + "patch": { + "summary": "Update the authorization configuration", + "description": "Requires admin permissions.\n", + "parameters": [ + { + "$ref": "#/components/parameters/If-Match" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthzConfigPatch" + } + } + } + }, + "responses": { + "200": { + "description": "The updated platform configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthzConfig" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["platform"] + } + }, "/platform/redirects": { "get": { "summary": "Return a list of redirects from old URLs to new locations", @@ -391,6 +447,41 @@ } }, "required": ["error"] + }, + "AuthzFlag": { + "type": "string", + "enum": ["admins_only", "registered_users"], + "description": "Controls whether only admins or every registered user can perform a specific action.\n" + }, + "AuthzConfig": { + "description": "The authorization configuration for the whole platform.", + "type": "object", + "properties": { + "etag": { + "$ref": "#/components/schemas/ETag" + }, + "create_projects": { + "$ref": "#/components/schemas/AuthzFlag" + }, + "create_groups": { + "$ref": "#/components/schemas/AuthzFlag" + } + }, + "required": ["etag", "create_projects", "create_groups"], + "additionalProperties": false + }, + "AuthzConfigPatch": { + "description": "Patch of the configuration of RenkuLab", + "type": "object", + "properties": { + "create_projects": { + "$ref": "#/components/schemas/AuthzFlag" + }, + "create_groups": { + "$ref": "#/components/schemas/AuthzFlag" + } + }, + "additionalProperties": false } }, "responses": { diff --git a/client/src/features/projectsV2/api/projectV2.openapi.json b/client/src/features/projectsV2/api/projectV2.openapi.json index 60e3fe95d0..81114656e2 100644 --- a/client/src/features/projectsV2/api/projectV2.openapi.json +++ b/client/src/features/projectsV2/api/projectV2.openapi.json @@ -1197,7 +1197,7 @@ "description": "A container image", "type": "string", "maxLength": 500, - "pattern": "^[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*(\\/[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*)*(:[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}|@sha256:[a-fA-F0-9]{64}){0,1}$", + "minLength": 3, "example": "renku/renkulab-py:3.10-0.18.1" }, "DefaultUrl": { diff --git a/client/src/features/sessionsV2/AddSessionLauncherButton.tsx b/client/src/features/sessionsV2/AddSessionLauncherButton.tsx index d71fafc823..f1d263fcfa 100644 --- a/client/src/features/sessionsV2/AddSessionLauncherButton.tsx +++ b/client/src/features/sessionsV2/AddSessionLauncherButton.tsx @@ -21,7 +21,7 @@ import { useCallback, useState } from "react"; import { PlusLg } from "react-bootstrap-icons"; import { Button } from "reactstrap"; -import NewSessionLauncherModal from "./components/SessionModals/NewSessionLauncherModal"; +import NewLauncherModal from "./components/SessionModals/NewLauncherModal"; export default function AddSessionLauncherButton({ "data-cy": dataCy, @@ -38,9 +38,9 @@ export default function AddSessionLauncherButton({ return ( <> {styleBtn === "iconTextBtn" ? ( - ) : ( )} - + ); } diff --git a/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx b/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx index 9c675d2003..e6d4c3e24a 100644 --- a/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx +++ b/client/src/features/sessionsV2/DataConnectorSecretsModal.tsx @@ -59,6 +59,13 @@ const CONTEXT_STRINGS = { testError: "The data connector could not be mounted. Please retry with different credentials, or skip the data connector. If you skip, the data connector will not be mounted in the session.", }, + job: { + continueButton: "Continue", + dataCy: "job-data-connector-credentials-modal", + header: "Job Storage Credentials", + testError: + "The data connector could not be mounted. Please retry with different credentials, or skip the data connector. If you skip, the data connector will not be mounted in the job.", + }, storage: { continueButton: "Test and Save", dataCy: "data-connector-credentials-modal", @@ -113,7 +120,7 @@ function DataConnectorSecrets({ dataConnectorConfig.savedCredentialFields.map((secret) => [ storageSecretNameToFieldName({ name: secret }), secret, - ]), + ]) ) : {}; @@ -158,7 +165,7 @@ function DataConnectorSecrets({ } interface DataConnectorSecretsModalProps { - context?: "session" | "storage"; + context?: "session" | "job" | "storage"; isOpen: boolean; onCancel: () => void; onStart: (dataConnectorConfigs: DataConnectorConfiguration[]) => void; @@ -176,16 +183,16 @@ export default function DataConnectorSecretsModal({ initialDataConnectorConfigs == null ? [] : initialDataConnectorConfigs.filter( - (config) => config.sensitiveFieldDefinitions.length === 0, + (config) => config.sensitiveFieldDefinitions.length === 0 ), - [initialDataConnectorConfigs], + [initialDataConnectorConfigs] ); const [dataConnectorConfigs, setDataConnectorConfigs] = useState( initialDataConnectorConfigs == null ? [] : initialDataConnectorConfigs.filter( - (config) => config.sensitiveFieldDefinitions.length > 0, - ), + (config) => config.sensitiveFieldDefinitions.length > 0 + ) ); const [index, setIndex] = useState(0); const { control, handleSubmit, reset: resetForm } = useForm(); @@ -204,7 +211,7 @@ export default function DataConnectorSecretsModal({ onStart([...noCredentialsConfigs, ...csConfigs]); } }, - [index, noCredentialsConfigs, onStart, resetForm, validationResult], + [index, noCredentialsConfigs, onStart, resetForm, validationResult] ); const onSkip = useCallback(() => { @@ -256,7 +263,7 @@ export default function DataConnectorSecretsModal({ newCloudStorageConfigs[index] = config; setDataConnectorConfigs(newCloudStorageConfigs); }, - [dataConnectorConfigs, index, validateCloudStorageConnection], + [dataConnectorConfigs, index, validateCloudStorageConnection] ); useEffect(() => { @@ -278,7 +285,7 @@ export default function DataConnectorSecretsModal({ if (dataConnectorConfigs == null) return null; if (dataConnectorConfigs.length < 1) return null; const hasSavedCredentials = dataConnectorConfigs.some( - (csc) => csc.savedCredentialFields?.length > 0, + (csc) => csc.savedCredentialFields?.length > 0 ); return ( @@ -327,10 +334,8 @@ export default function DataConnectorSecretsModal({ ); } -interface CredentialsButtonsProps extends Pick< - DataConnectorSecretsModalProps, - "onCancel" -> { +interface CredentialsButtonsProps + extends Pick { context: NonNullable; hasSavedCredentials: boolean; onSkip: () => void; @@ -352,7 +357,9 @@ function CredentialsButtons({ Cancel - {context === "session" && } + {(context === "session" || context === "job") && ( + + )} {context === "storage" && ( index && "text-decoration-none", + idx > index && "text-decoration-none" )} disabled={idx >= index} onClick={() => { @@ -485,7 +492,8 @@ function SaveCredentialsInput({ ); } -interface SensitiveFieldWidgetProps extends DataConnectorConfigurationSecretsProps { +interface SensitiveFieldWidgetProps + extends DataConnectorConfigurationSecretsProps { credentialFieldDict: Record; field: { name: string; @@ -573,7 +581,7 @@ function SensitiveFieldInput({ "form-control", "rounded-0", "rounded-start", - fieldState.error && "is-invalid", + fieldState.error && "is-invalid" )} placeholder={""} {...field} @@ -612,9 +620,11 @@ function SensitiveFieldInput({ } function SkipConnectionTestButton({ + context, onSkip, -}: Pick) { +}: Pick) { const skipButtonRef = useRef(null); + const targetLabel = context === "job" ? "job" : "session"; return ( <> @@ -624,7 +634,7 @@ function SkipConnectionTestButton({ - Skip the data connector. It will not be mounted in the session. + {`Skip the data connector. It will not be mounted in the ${targetLabel}.`} ); diff --git a/client/src/features/sessionsV2/DeleteSessionLauncherModal.tsx b/client/src/features/sessionsV2/DeleteSessionLauncherModal.tsx index 2e542853f6..728dccd057 100644 --- a/client/src/features/sessionsV2/DeleteSessionLauncherModal.tsx +++ b/client/src/features/sessionsV2/DeleteSessionLauncherModal.tsx @@ -25,6 +25,10 @@ import { WarnAlert } from "../../components/Alert"; import RtkOrDataServicesError from "../../components/errors/RtkOrDataServicesError"; import type { SessionLauncher } from "./api/sessionLaunchersV2.api"; import { useDeleteSessionLaunchersByLauncherIdMutation as useDeleteSessionLauncherMutation } from "./api/sessionLaunchersV2.api"; +import { + getLauncherCategory, + getLauncherCategoryDefinition, +} from "./session.utils"; interface DeleteSessionLauncherModalProps { isOpen: boolean; @@ -40,6 +44,8 @@ export default function DeleteSessionLauncherModal({ sessionsLength, }: DeleteSessionLauncherModalProps) { const [deleteSessionLauncher, result] = useDeleteSessionLauncherMutation(); + const launcherCategory = getLauncherCategory(launcher); + const launcherDefinition = getLauncherCategoryDefinition(launcherCategory); const onDelete = useCallback(() => { deleteSessionLauncher({ @@ -75,32 +81,37 @@ export default function DeleteSessionLauncherModal({ tag="h2" toggle={toggle} > - Delete session launcher + Delete {launcherDefinition.text.inline} launcher {result.error && }

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

{sessionsLength > 0 && (

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

)} {sessionsLength === 0 && (

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

)} @@ -119,7 +130,7 @@ export default function DeleteSessionLauncherModal({ role="button" > - Delete Session launcher + Delete {launcherDefinition.text.inline} launcher diff --git a/client/src/features/sessionsV2/SaveCloudStorageCredentials.tsx b/client/src/features/sessionsV2/SaveCloudStorageCredentials.tsx new file mode 100644 index 0000000000..cfb963eaab --- /dev/null +++ b/client/src/features/sessionsV2/SaveCloudStorageCredentials.tsx @@ -0,0 +1,178 @@ +/*! + * Copyright 2026 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { useEffect, useMemo, useState } from "react"; + +import RtkOrDataServicesError from "~/components/errors/RtkOrDataServicesError"; +import ProgressStepsIndicator, { + ProgressStyle, + ProgressType, + StatusStepProgressBar, + StepsProgressBar, +} from "~/components/progress/ProgressSteps"; +import { storageDefinitionAfterSavingCredentialsFromConfig } from "../cloudStorage/projectCloudStorage.utils"; +import { usePatchDataConnectorsByDataConnectorIdSecretsMutation } from "../dataConnectorsV2/api/data-connectors.enhanced-api"; +import { shouldCloudStorageSaveCredentials } from "./sessionLaunchValidation.utils"; +import type { SessionStartDataConnectorConfiguration } from "./startSessionOptionsV2.types"; + +import progressBoxStyles from "~/components/progress/ProgressBox.module.scss"; + +interface SaveCloudStorageCredentialsProps { + dataConnectors: SessionStartDataConnectorConfiguration[]; + onComplete: (configs: SessionStartDataConnectorConfiguration[]) => void; + title?: string; +} + +export default function SaveCloudStorageCredentials({ + dataConnectors, + onComplete, + title = "Saving credentials", +}: SaveCloudStorageCredentialsProps) { + const [steps, setSteps] = useState([]); + const [saveCredentials, saveCredentialsResult] = + usePatchDataConnectorsByDataConnectorIdSecretsMutation(); + + const credentialsToSave = useMemo(() => { + return dataConnectors + .filter(shouldCloudStorageSaveCredentials) + .map((cs) => ({ + storageName: cs.dataConnector.name, + storageId: cs.dataConnector.id, + secrets: cs.sensitiveFieldValues, + })); + }, [dataConnectors]); + + const [results, setResults] = useState( + credentialsToSave.map(() => StatusStepProgressBar.WAITING) + ); + + const [index, setIndex] = useState(0); + const [hasFailed, setHasFailed] = useState(false); + const [failedError, setFailedError] = + useState(undefined); + + useEffect(() => { + const theSteps = credentialsToSave.map((cs, i) => ({ + id: i, + status: results[i], + step: `Saving credentials for ${cs.storageName}`, + })); + // TODO: fix react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect + setSteps(theSteps); + }, [credentialsToSave, results]); + + useEffect(() => { + if ( + hasFailed || + credentialsToSave.length < 1 || + index >= credentialsToSave.length + ) + return; + // TODO: fix react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect + setResults((prev) => { + const newResults = [...prev]; + newResults[index] = StatusStepProgressBar.EXECUTING; + return newResults; + }); + const storage = credentialsToSave[index]; + saveCredentials({ + dataConnectorId: storage.storageId, + dataConnectorSecretPatchList: Object.entries(storage.secrets).map( + ([key, value]) => ({ + name: key, + value, + }) + ), + }); + }, [credentialsToSave, hasFailed, index, saveCredentials]); + + useEffect(() => { + if ( + saveCredentialsResult.isUninitialized || + saveCredentialsResult.isLoading + ) + return; + if (saveCredentialsResult.data != null) { + // TODO: fix react-hooks/set-state-in-effect + // eslint-disable-next-line react-hooks/set-state-in-effect + setResults((prev) => { + const newResults = [...prev]; + newResults[index] = StatusStepProgressBar.READY; + return newResults; + }); + saveCredentialsResult.reset(); + setIndex((prev) => prev + 1); + return; + } + if (saveCredentialsResult.error != null) { + setResults((prev) => { + const newResults = [...prev]; + newResults[index] = StatusStepProgressBar.FAILED; + return newResults; + }); + setFailedError(saveCredentialsResult.error); + setHasFailed(true); + saveCredentialsResult.reset(); + } + }, [index, saveCredentialsResult]); + + useEffect(() => { + if (hasFailed || saveCredentialsResult.isLoading) { + return; + } + if (index >= credentialsToSave.length) { + const cloudStorageConfigs = dataConnectors.map((cs) => + storageDefinitionAfterSavingCredentialsFromConfig(cs) + ); + onComplete(cloudStorageConfigs); + } + }, [ + credentialsToSave.length, + dataConnectors, + hasFailed, + index, + onComplete, + saveCredentialsResult.isLoading, + ]); + + return ( +
+ {hasFailed && failedError != null && ( + + )} + +
+ ); +} diff --git a/client/src/features/sessionsV2/SessionList/JobCard.tsx b/client/src/features/sessionsV2/SessionList/JobCard.tsx new file mode 100644 index 0000000000..cc59a543db --- /dev/null +++ b/client/src/features/sessionsV2/SessionList/JobCard.tsx @@ -0,0 +1,152 @@ +/*! + * Copyright 2026 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { KeyboardEvent, MouseEvent, useCallback } from "react"; +import { Col, Row } from "reactstrap"; + +import { + getLauncherCategoryDefinition, + sessionLauncherKindToCategory, +} from "~/features/sessionsV2/session.utils"; +import { Project } from "../../projectsV2/api/projectV2.api"; +import ActiveSessionButton from "../components/SessionButton/ActiveSessionButton"; +import { + SessionStatusV2Badge, + SessionStatusV2Description, +} from "../components/SessionStatus/SessionStatus"; +import { getShowSessionUrlByProject } from "../SessionsV2"; +import { SessionV2 } from "../sessionsV2.types"; + +interface JobCardProps { + project: Project; + session?: SessionV2; + onOpen?: (submissionId: string) => void; +} +export default function JobCard({ project, session, onOpen }: JobCardProps) { + const handleOpen = useCallback(() => { + if (session?.submission_id && onOpen) { + onOpen(session.submission_id); + } + }, [onOpen, session]); + + const stopPropagation = useCallback((event: MouseEvent | KeyboardEvent) => { + event.stopPropagation(); + }, []); + + if (!session) return null; + + const launcherCategory = sessionLauncherKindToCategory(session.session_type); + const launcherDefinition = getLauncherCategoryDefinition(launcherCategory); + + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleOpen(); + } + } + : undefined + } + role={onOpen ? "button" : undefined} + aria-label={`Open details of job with submission id ${session.submission_id}`} + tabIndex={onOpen ? 0 : undefined} + > +
+ + + + {launcherDefinition.text.display}: {session.submission_id} + + + + +
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/client/src/features/sessionsV2/SessionList/SessionCard.tsx b/client/src/features/sessionsV2/SessionList/SessionCard.tsx index eb8a767673..b3439d2907 100644 --- a/client/src/features/sessionsV2/SessionList/SessionCard.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionCard.tsx @@ -19,6 +19,10 @@ import cx from "classnames"; import { Col, Row } from "reactstrap"; +import { + getLauncherCategoryDefinition, + sessionLauncherKindToCategory, +} from "~/features/sessionsV2/session.utils.ts"; import { Project } from "../../projectsV2/api/projectV2.api"; import ActiveSessionButton from "../components/SessionButton/ActiveSessionButton"; import { @@ -38,7 +42,9 @@ interface SessionCardProps { export default function SessionCard({ project, session }: SessionCardProps) { if (!session) return null; - const stylesPerSession = getSessionStatusStyles(session); + const launcherCategory = sessionLauncherKindToCategory(session.session_type); + const stylesPerSession = getSessionStatusStyles(session, launcherCategory); + const launcherDefinition = getLauncherCategoryDefinition(launcherCategory); return (
- Session line indicator -
+ {launcherCategory === "session" && ( + Session line indicator + )} +
@@ -67,11 +81,14 @@ export default function SessionCard({ project, session }: SessionCardProps) { "d-inline-block", "link-primary", "text-body", - "mt-1", + "mt-1" )} > - Session + + {launcherDefinition.text.display} + + {session.submission_id ? `: ${session.submission_id}` : ""} - +
diff --git a/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx index 42587c0165..0d779af9c9 100644 --- a/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx @@ -19,10 +19,17 @@ import { skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; import { useContext, useMemo } from "react"; -import { CircleFill, Link45deg, Pencil, Trash } from "react-bootstrap-icons"; +import { + CircleFill, + Link45deg, + Pencil, + PlayCircle, + Trash, +} from "react-bootstrap-icons"; import { Card, CardBody, Col, DropdownItem, Row } from "reactstrap"; import SessionEnvironmentGitLabWarningBadge from "~/features/legacy/SessionEnvironmentGitLabWarnBadge"; +import JobCard from "~/features/sessionsV2/SessionList/JobCard"; import { Loader } from "../../../components/Loader"; import AppContext from "../../../utils/context/appContext"; import { DEFAULT_APP_PARAMS } from "../../../utils/context/appParams.constants"; @@ -47,6 +54,11 @@ import { import { SessionLauncherButtons } from "../components/SessionLauncherButtons"; import SessionImageBadge from "../components/SessionStatus/SessionImageBadge"; import { SessionBadge } from "../components/SessionStatus/SessionStatus"; +import { + getLauncherCategory, + getLauncherCategoryDefinition, + sessionLauncherKindToCategory, +} from "../session.utils"; import { SessionV2 } from "../sessionsV2.types"; import SessionCard from "./SessionCard"; @@ -62,6 +74,7 @@ interface SessionLauncherCardProps { toggleUpdateEnvironment?: () => void; toggleShareLink?: () => void; toggleSessionView?: () => void; + openSessionViewWithJob?: (submissionId: string) => void; } export default function SessionLauncherCard({ launcher, @@ -73,6 +86,7 @@ export default function SessionLauncherCard({ toggleUpdateEnvironment, toggleSessionView, toggleShareLink, + openSessionViewWithJob, }: SessionLauncherCardProps) { const { params } = useContext(AppContext); const environment = launcher?.environment; @@ -86,14 +100,21 @@ export default function SessionLauncherCard({ const { data: builds, isLoading } = useGetBuildsQuery( imageBuildersEnabled && isCodeEnvironment ? { environmentId: environment.id } - : skipToken, + : skipToken ); const lastBuild = builds?.at(0); const lastSuccessfulBuild = builds?.find( - (build) => build.status === "succeeded" && build.id !== lastBuild?.id, + (build) => build.status === "succeeded" && build.id !== lastBuild?.id ); const hasSession = !!sessions?.length; + const sessionType = sessions?.at(0)?.session_type ?? "interactive"; + const launcherCategory = sessionLauncherKindToCategory( + launcher?.launcher_type || sessionType + ); + const launcherDefinition = getLauncherCategoryDefinition(launcherCategory); + const LauncherTypeIcon = launcherDefinition?.icon || PlayCircle; + const launcherTypeLabel = launcherDefinition?.text.display || null; sessionLaunchersV2Api.endpoints.getEnvironmentsByEnvironmentIdBuilds.useQuerySubscription( isCodeEnvironment && lastBuild?.status === "in_progress" @@ -101,15 +122,15 @@ export default function SessionLauncherCard({ : skipToken, { pollingInterval: 1_000, - }, + } ); const otherLauncherActions = launcher && - toggleUpdate && - toggleDelete && - toggleShareLink && - toggleUpdateEnvironment && ( - - classes.some(({ id }) => id === launcher.resource_class_id), + classes.some(({ id }) => id === launcher.resource_class_id) ); }, [launcher?.resource_class_id, resourcePools]); @@ -154,45 +175,72 @@ export default function SessionLauncherCard({ styles.SessionLauncherCard, "cursor-pointer", "shadow-none", - "rounded-0", + "rounded-0" )} data-cy="session-launcher-item" - onClick={toggleSessionView} - tabIndex={0} + tabIndex={-1} > -
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleSessionView?.(); + } + }} + role="button" + tabIndex={0} + > - - - - Session Launcher - - - - {environment?.environment_kind === "GLOBAL" ? ( - - - Global environment - - ) : isCodeEnvironment ? ( - - - Code based environment - - ) : isExternalImageEnvironment ? ( - - - External image environment + {launcher && ( + + + + + {launcherTypeLabel}{" "} + Launcher - ) : null} - - + + + {environment?.environment_kind === "GLOBAL" ? ( + + + Global environment + + ) : isCodeEnvironment ? ( + + + Code based environment + + ) : isExternalImageEnvironment ? ( + + + External image environment + + ) : null} + + + )} @@ -292,7 +340,7 @@ export default function SessionLauncherCard({ "d-flex", "flex-column", "align-items-end", - "gap-2", + "gap-2" )} > {sessions && sessions?.length > 0 && - sessions.map((session) => ( - - ))} + sessions.map((session) => { + if (session.session_type === "interactive") + return ( + + ); + return ( + + ); + })}
)} @@ -345,62 +404,72 @@ export default function SessionLauncherCard({ ); } -interface SessionLauncherDropdownActionsProps { +interface LauncherDropdownActionsProps { launcher: SessionLauncher; - toggleUpdate: () => void; - toggleDelete: () => void; - toggleUpdateEnvironment: () => void; - toggleShareLink: () => void; + toggleUpdate?: () => void; + toggleDelete?: () => void; + toggleUpdateEnvironment?: () => void; + toggleShareLink?: () => void; project: Project; } -function SessionLauncherDropdownActions({ +function LauncherDropdownActions({ launcher, toggleDelete, toggleUpdate, toggleUpdateEnvironment, toggleShareLink, -}: SessionLauncherDropdownActionsProps) { +}: LauncherDropdownActionsProps) { const { project_id: projectId } = launcher; const permissions = useProjectPermissions({ projectId }); - + const launcherCategory = getLauncherCategory(launcher); return ( <> - - - Edit environment - - - - Edit launcher - - - - Share session launch link - - - - - Delete launcher - + {toggleUpdateEnvironment && ( + + + Edit environment + + )} + {toggleUpdate && ( + + + Edit launcher + + )} + {toggleShareLink && launcherCategory === "session" && ( + + + Share session launch link + + )} + {toggleDelete && ( + <> + + + + Delete launcher + + + )} } requestedPermission="write" diff --git a/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx b/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx index 394b14c278..85bbe772a6 100644 --- a/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionLauncherDisplay.tsx @@ -25,6 +25,14 @@ import { useGetSessionsQuery as useGetSessionsQueryV2 } from "../api/sessionsV2. import UpdateSessionLauncherMetadataModal from "../components/SessionModals/UpdateSessionLauncherMetadataModal"; import UpdateSessionLauncherEnvironmentModal from "../components/SessionModals/UpdateSessionLauncherModal"; import DeleteSessionV2Modal from "../DeleteSessionLauncherModal"; +import { + buildLauncherHash, + buildLauncherJobHash, + isLauncherHashOpen, + parseLauncherHash, + toggleLauncherHash, +} from "../session.utils"; +import { SESSION_LAUNCHER_KIND } from "../sessionsV2.types"; import SessionLaunchLinkModal from "../SessionView/SessionLaunchLinkModal"; import { SessionView } from "../SessionView/SessionView"; import SessionLauncherCard from "./SessionLauncherCard"; @@ -57,19 +65,37 @@ export function SessionLauncherDisplay({ }, []); const [hash, setHash] = useLocationHash(); - const launcherHash = useMemo(() => `launcher-${launcher.id}`, [launcher.id]); + const launcherHash = useMemo( + () => buildLauncherHash(launcher.id), + [launcher.id] + ); const isSessionViewOpen = useMemo( - () => hash === launcherHash, - [hash, launcherHash], + () => isLauncherHashOpen(hash, launcher.id), + [hash, launcher.id] ); + const openJobSubmissionId = useMemo(() => { + const parsed = parseLauncherHash(hash); + if (parsed.launcherId !== launcher.id) { + return undefined; + } + return parsed.submissionId; + }, [hash, launcher.id]); const toggleSessionView = useCallback(() => { - setHash((prev) => { - const isOpen = prev === launcherHash; - return isOpen ? "" : launcherHash; - }); - }, [launcherHash, setHash]); + setHash((prev) => toggleLauncherHash(prev, launcher.id)); + }, [launcher.id, setHash]); + const closeSessionView = useCallback(() => { + setHash((prev) => (isLauncherHashOpen(prev, launcher.id) ? "" : prev)); + }, [launcher.id, setHash]); + const openSessionViewWithJob = useCallback( + (submissionId: string) => { + setHash(buildLauncherJobHash(launcher.id, submissionId)); + }, + [launcher.id, setHash] + ); - const { data: sessions } = useGetSessionsQueryV2({}); + const { data: sessions } = useGetSessionsQueryV2({ + sessionType: launcher.launcher_type, + }); const filteredSessions = useMemo( () => @@ -77,10 +103,10 @@ export function SessionLauncherDisplay({ ? sessions.filter( (session) => session.launcher_id === launcher.id && - session.project_id === project.id, + session.project_id === project.id ) : [], - [launcher.id, project.id, sessions], + [launcher.id, project.id, sessions] ); return ( @@ -94,16 +120,22 @@ export function SessionLauncherDisplay({ toggleUpdate={toggleUpdate} toggleUpdateEnvironment={toggleUpdateEnvironment} toggleDelete={toggleDelete} - toggleShareLink={toggleShareLink} + toggleShareLink={ + launcher.launcher_type === SESSION_LAUNCHER_KIND.INTERACTIVE + ? toggleShareLink + : undefined + } toggleSessionView={toggleSessionView} + openSessionViewWithJob={openSessionViewWithJob} /> void; + onCancel?: () => void; + continueLabel?: string; + title?: string; + warningIntro?: string; } export default function SessionRepositoriesModal({ isOpen, project, + onSkip: onSkipProp, + onCancel: onCancelProp, + continueLabel = "Launch anyway", + title = "Session repositories not accessible", + warningIntro = "your attention before launching the session", }: SessionRepositoriesModalProps) { const navigate = useNavigate(); - const onCancel = useCallback(() => { + const defaultOnCancel = useCallback(() => { const url = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { namespace: project.namespace, slug: project.slug, }); navigate(url); }, [navigate, project.namespace, project.slug]); + const onCancel = onCancelProp ?? defaultOnCancel; const projectPermissions = useProjectPermissions({ projectId: project.id }); const { data, error, isLoading } = useGetRepositoriesQuery( - project.repositories ?? [], + project.repositories ?? [] ); const repoWithInterruptions = useMemo(() => { @@ -78,16 +89,16 @@ export default function SessionRepositoriesModal({ data.filter( (repo) => repo.error || - (repo.data && - shouldInterrupt(repo.data, !!projectPermissions?.write)), + (repo.data && shouldInterrupt(repo.data, !!projectPermissions?.write)) ) ?? [] ); }, [data, isLoading, projectPermissions?.write]); const dispatch = useAppDispatch(); - const onSkip = useCallback(() => { + const defaultOnSkip = useCallback(() => { dispatch(startSessionOptionsV2Slice.actions.setRepositoriesReady(true)); }, [dispatch]); + const onSkip = onSkipProp ?? defaultOnSkip; const content = isLoading || !data ? ( @@ -107,7 +118,7 @@ export default function SessionRepositoriesModal({ {repoWithInterruptions.length === 1 ? `is ${repoWithInterruptions.length} repository that requires` : `are ${repoWithInterruptions.length} repositories that require`}{" "} - your attention before launching the session: + your attention {warningIntro}:

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

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

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

{launcher && launcher.environment?.default_url ? ( @@ -588,7 +669,7 @@ export function SessionView({ className={cx( "align-items-center", "d-flex", - "justify-content-between", + "justify-content-between" )} >

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

+ Job {session.submission_id} +

+ +
+ +
+ + +
+ + {sessionError && ( + + {sessionError} + + )} + + + {!isLoading && resourceRequests && ( + <> + + + + )} +
+
+ ); +} + +function JobList({ sessions, project, openJobSubmissionId }: JobListProps) { + const resolvedSubmissionId = useMemo( + () => resolveOpenJobSubmissionId(openJobSubmissionId, sessions), + [openJobSubmissionId, sessions] + ); + const initialOpenJobs = useMemo( + () => + resolvedSubmissionId + ? [getJobAccordionTargetId(resolvedSubmissionId)] + : [], + [resolvedSubmissionId] + ); + const [openJobs, setOpenJobs] = useState(initialOpenJobs); + + useEffect(() => { + setOpenJobs(initialOpenJobs); + }, [initialOpenJobs]); + + return ( +
+ + setOpenJobs((prev) => [...prev, targetId]) + } + > + {sessions.map((session) => ( + + ))} + +
+ ); +} diff --git a/client/src/features/sessionsV2/SessionsV2.tsx b/client/src/features/sessionsV2/SessionsV2.tsx index 7d7981d7ad..788457196d 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, @@ -44,12 +44,12 @@ import { useGetSessionsQuery as useGetSessionsQueryV2 } from "./api/sessionsV2.a import { LauncherEnvironmentIcon } from "./components/SessionForm/LauncherEnvironmentIcon"; import SessionLauncherCard from "./SessionList/SessionLauncherCard"; import { SessionLauncherDisplay } from "./SessionList/SessionLauncherDisplay"; -import { SessionV2 } from "./sessionsV2.types"; +import { SESSION_LAUNCHER_KIND, SessionV2 } from "./sessionsV2.types"; import { SessionView } from "./SessionView/SessionView"; export function getShowSessionUrlByProject( project: Project, - sessionName: string, + sessionName: string ) { return generatePath(ABSOLUTE_ROUTES.v2.projects.show.sessions.show, { namespace: project.namespace, @@ -76,7 +76,9 @@ export default function SessionsV2({ project }: SessionsV2Props) { data: sessions, error: sessionsError, isLoading: isLoadingSessions, - } = useGetSessionsQueryV2({}); + } = useGetSessionsQueryV2({ + sessionType: SESSION_LAUNCHER_KIND.NON_INTERACTIVE, + }); const isLoading = isLoadingLaunchers || isLoadingSessions; const error = launchersError || sessionsError; @@ -87,10 +89,10 @@ export default function SessionsV2({ project }: SessionsV2Props) { ? sessions.filter( (session) => launchers.every(({ id }) => session.launcher_id !== id) && - session.project_id === projectId, + session.project_id === projectId ) : [], - [launchers, sessions, projectId], + [launchers, sessions, projectId] ); const loading = isLoading && ( @@ -109,8 +111,8 @@ export default function SessionsV2({ project }: SessionsV2Props) { <>

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

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

- - Sessions + + Launchers

{totalSessions}
@@ -155,7 +157,7 @@ export default function SessionsV2({ project }: SessionsV2Props) { enabled={
@@ -246,11 +248,11 @@ function OrphanSession({ session, project }: OrphanSessionProps) { const [hash, setHash] = useLocationHash(); const sessionHash = useMemo( () => `orphan-session-${session.name}`, - [session.name], + [session.name] ); const isSessionViewOpen = useMemo( () => hash === sessionHash, - [hash, sessionHash], + [hash, sessionHash] ); const toggleSessionView = useCallback(() => { setHash((prev) => { diff --git a/client/src/features/sessionsV2/StartSessionButton.tsx b/client/src/features/sessionsV2/StartSessionButton.tsx index 7e24b34507..06faa86149 100644 --- a/client/src/features/sessionsV2/StartSessionButton.tsx +++ b/client/src/features/sessionsV2/StartSessionButton.tsx @@ -23,7 +23,10 @@ import { PlayCircle } from "react-bootstrap-icons"; import { generatePath, Link } from "react-router"; import { UncontrolledTooltip } from "reactstrap"; +import useProjectPermissions from "~/features/ProjectPageV2/utils/useProjectPermissions.hook"; import { useGetEnvironmentsByEnvironmentIdBuildsQuery as useGetBuildsQuery } from "~/features/sessionsV2/api/sessionLaunchersV2.api"; +import SubmitJobLauncherAction from "~/features/sessionsV2/components/SubmitJobLauncherAction"; +import { LauncherCategory } from "~/features/sessionsV2/sessionsV2.types"; import AppContext from "~/utils/context/appContext"; import { DEFAULT_APP_PARAMS } from "~/utils/context/appParams.constants"; import { ButtonWithMenuV2 } from "../../components/buttons/Button"; @@ -40,12 +43,15 @@ interface StartSessionButtonProps { useOldImage?: boolean; otherActions?: ReactNode; isDisabledDropdownToggle?: boolean; + launcherCategory: LauncherCategory; } export default function StartSessionButton({ launcher, namespace, slug, + launcherCategory, + isDisabledDropdownToggle, }: StartSessionButtonProps) { const startUrl = generatePath( ABSOLUTE_ROUTES.v2.projects.show.sessions.start, @@ -53,7 +59,7 @@ export default function StartSessionButton({ launcherId: launcher.id, namespace, slug, - }, + } ); const environment = launcher?.environment; const isExternalImageEnvironment = @@ -64,7 +70,7 @@ export default function StartSessionButton({ environment.environment_kind === "CUSTOM" && environment.container_image ? { imageUrl: environment.container_image } - : skipToken, + : skipToken ); const { params } = useContext(AppContext); const imageBuildersEnabled = @@ -72,36 +78,49 @@ export default function StartSessionButton({ const { data: builds } = useGetBuildsQuery( imageBuildersEnabled && environment.environment_image_source === "build" ? { environmentId: environment.id } - : skipToken, + : skipToken ); + const permissions = useProjectPermissions({ projectId: launcher.project_id }); + const hasSuccessfulBuild = builds?.find( - (build) => build.status === "succeeded", + (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 launchButtonDisableReason = `No image available. ${ + permissions.write + ? "Run the Build action" + : "Contact the project administrator " + } to generate an image.`; const launchAction = ( - - - {force ? "Force launch" : "Launch"} - + {launcherCategory === "session" ? ( + + + {force ? "Force launch" : "Launch"} + + ) : ( + + )} {isLaunchButtonDisabled && ( ); + if (launcherCategory === "job" && isDisabledDropdownToggle) + return launchAction; + return ( <> ({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/members`, + }), + }), + postResourcePoolsByResourcePoolIdMembers: build.mutation< + PostResourcePoolsByResourcePoolIdMembersApiResponse, + PostResourcePoolsByResourcePoolIdMembersApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/members`, + method: "POST", + body: queryArg.poolMembers, + }), + }), + putResourcePoolsByResourcePoolIdMembers: build.mutation< + PutResourcePoolsByResourcePoolIdMembersApiResponse, + PutResourcePoolsByResourcePoolIdMembersApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/members`, + method: "PUT", + body: queryArg.poolMembers, + }), + }), + getResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberId: build.query< + GetResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdApiResponse, + GetResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/members/${queryArg.memberType}/${queryArg.memberId}`, + }), + }), + deleteResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberId: + build.mutation< + DeleteResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdApiResponse, + DeleteResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/members/${queryArg.memberType}/${queryArg.memberId}`, + method: "DELETE", + }), + }), getResourcePoolsByResourcePoolIdQuota: build.query< GetResourcePoolsByResourcePoolIdQuotaApiResponse, GetResourcePoolsByResourcePoolIdQuotaApiArg @@ -493,6 +539,41 @@ export type DeleteResourcePoolsByResourcePoolIdUsersAndUserIdApiArg = { resourcePoolId: number; userId: string; }; +export type GetResourcePoolsByResourcePoolIdMembersApiResponse = + /** status 200 The list of members */ PoolMembersResponse; +export type GetResourcePoolsByResourcePoolIdMembersApiArg = { + resourcePoolId: number; +}; +export type PostResourcePoolsByResourcePoolIdMembersApiResponse = + /** status 201 The members were added */ PoolMembersResponse; +export type PostResourcePoolsByResourcePoolIdMembersApiArg = { + resourcePoolId: number; + /** List of members */ + poolMembers: PoolMembers; +}; +export type PutResourcePoolsByResourcePoolIdMembersApiResponse = + /** status 200 The members were set */ PoolMembersResponse; +export type PutResourcePoolsByResourcePoolIdMembersApiArg = { + resourcePoolId: number; + /** List of members */ + poolMembers: PoolMembers; +}; +export type GetResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdApiResponse = + /** status 200 The member belongs to the resource pool */ PoolMemberResponse; +export type GetResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdApiArg = + { + resourcePoolId: number; + memberType: "user" | "group" | "project"; + memberId: string; + }; +export type DeleteResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdApiResponse = + unknown; +export type DeleteResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdApiArg = + { + resourcePoolId: number; + memberType: "user" | "group" | "project"; + memberId: string; + }; export type GetResourcePoolsByResourcePoolIdQuotaApiResponse = /** status 200 The resource quota for the resource pool */ QuotaWithId; export type GetResourcePoolsByResourcePoolIdQuotaApiArg = { @@ -549,6 +630,7 @@ export type NodeAffinity = { }; export type NodeAffinityList = NodeAffinity[]; export type IntegerId = number; +export type QuotaEnforced = boolean; export type ResourceClassWithId = { name: Name; default: DefaultFlag; @@ -560,6 +642,7 @@ export type ResourceClassWithId = { tolerations?: K8SLabelList; node_affinities?: NodeAffinityList; id: IntegerId; + quota_enforced?: QuotaEnforced; }; export type ErrorResponse = { error: { @@ -719,6 +802,7 @@ export type ResourceClass = { default_storage: Storage; tolerations?: K8SLabelList; node_affinities?: NodeAffinityList; + quota_enforced?: QuotaEnforced; }; export type ResourceClasses = ResourceClass[]; export type ResourcePool = { @@ -763,6 +847,7 @@ export type ResourceClassProperties = { default_storage?: Storage; tolerations?: K8SLabelList; node_affinities?: NodeAffinityList; + quota_enforced?: QuotaEnforced; }; export type ResourceClassPatchWithId = ResourceClassProperties & { id: IntegerId; @@ -810,6 +895,49 @@ export type PoolUserWithId = { no_default_access?: boolean; }; export type PoolUsersWithId = PoolUserWithId[]; +export type PoolMemberUserResponse = { + member_type: "user"; + id: UserId; + role: "viewer" | "prohibited"; + email: string; +}; +export type PoolMemberGroupResponse = { + member_type: "group"; + id: Ulid; + role: "group_viewer"; + slug: string; + name: string; +}; +export type PoolMemberProjectResponse = { + member_type: "project"; + id: Ulid; + role: "project_viewer"; + /** Full project namespace path (e.g. user/project) */ + namespace: string; + name: string; +}; +export type PoolMemberResponse = + | PoolMemberUserResponse + | PoolMemberGroupResponse + | PoolMemberProjectResponse; +export type PoolMembersResponse = PoolMemberResponse[]; +export type PoolMemberUser = { + member_type: "user"; + id: UserId; + role: "viewer" | "prohibited"; +}; +export type PoolMemberGroup = { + member_type: "group"; + id: Ulid; + role: "group_viewer"; +}; +export type PoolMemberProject = { + member_type: "project"; + id: Ulid; + role: "project_viewer"; +}; +export type PoolMember = PoolMemberUser | PoolMemberGroup | PoolMemberProject; +export type PoolMembers = PoolMember[]; export type ResourcePoolsWithId = ResourcePoolWithId[]; export type IntegerIds = IntegerId[]; export type Version = { @@ -845,6 +973,11 @@ export const { usePutResourcePoolsByResourcePoolIdUsersMutation, useGetResourcePoolsByResourcePoolIdUsersAndUserIdQuery, useDeleteResourcePoolsByResourcePoolIdUsersAndUserIdMutation, + useGetResourcePoolsByResourcePoolIdMembersQuery, + usePostResourcePoolsByResourcePoolIdMembersMutation, + usePutResourcePoolsByResourcePoolIdMembersMutation, + useGetResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdQuery, + useDeleteResourcePoolsByResourcePoolIdMembersAndMemberTypeMemberIdMutation, useGetResourcePoolsByResourcePoolIdQuotaQuery, usePutResourcePoolsByResourcePoolIdQuotaMutation, usePatchResourcePoolsByResourcePoolIdQuotaMutation, diff --git a/client/src/features/sessionsV2/api/computeResources.openapi.json b/client/src/features/sessionsV2/api/computeResources.openapi.json index 49c011fdd0..0bdb0ffd9a 100644 --- a/client/src/features/sessionsV2/api/computeResources.openapi.json +++ b/client/src/features/sessionsV2/api/computeResources.openapi.json @@ -386,7 +386,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -425,7 +427,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -474,7 +478,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -523,7 +529,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -547,7 +555,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -602,7 +612,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -653,7 +665,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -700,7 +714,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -757,7 +773,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -814,7 +832,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -846,7 +866,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -883,7 +905,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -915,7 +939,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -952,7 +978,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -984,7 +1012,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -1023,7 +1053,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -1092,7 +1124,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -1144,7 +1178,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -1191,7 +1227,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, { @@ -1214,6 +1252,254 @@ "tags": ["resource_pools"] } }, + "/resource_pools/{resource_pool_id}/members": { + "get": { + "summary": "Get all members of a resource pool", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + } + ], + "responses": { + "200": { + "description": "The list of members", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMembersResponse" + } + } + } + }, + "404": { + "description": "The resource pool does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource_pools"] + }, + "post": { + "summary": "Add members to a resource pool", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + } + ], + "requestBody": { + "description": "List of members", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMembers" + } + } + } + }, + "responses": { + "201": { + "description": "The members were added", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMembersResponse" + } + } + } + }, + "404": { + "description": "The resource pool or a member does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource_pools"] + }, + "put": { + "summary": "Set the members of a resource pool", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + } + ], + "requestBody": { + "description": "List of members", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMembers" + } + } + } + }, + "responses": { + "200": { + "description": "The members were set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMembersResponse" + } + } + } + }, + "404": { + "description": "The resource pool or a member does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource_pools"] + } + }, + "/resource_pools/{resource_pool_id}/members/{member_type}/{member_id}": { + "get": { + "summary": "Check if a specific member belongs to a resource pool", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + }, + { + "in": "path", + "name": "member_type", + "required": true, + "schema": { + "type": "string", + "enum": ["user", "group", "project"] + } + }, + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The member belongs to the resource pool", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMemberResponse" + } + } + } + }, + "404": { + "description": "The member does not belong to the resource pool, or the resource pool or member do not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource_pools"] + }, + "delete": { + "summary": "Remove a specific member from a resource pool", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + }, + { + "in": "path", + "name": "member_type", + "required": true, + "schema": { + "type": "string", + "enum": ["user", "group", "project"] + } + }, + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "The member was removed or it was not part of the pool" + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource_pools"] + } + }, "/resource_pools/{resource_pool_id}/quota": { "get": { "summary": "Get the quota associated with the resource pool", @@ -1223,7 +1509,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -1262,7 +1550,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -1311,7 +1601,9 @@ "name": "resource_pool_id", "required": true, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } } ], @@ -1723,6 +2015,9 @@ }, "node_affinities": { "$ref": "#/components/schemas/NodeAffinityList" + }, + "quota_enforced": { + "$ref": "#/components/schemas/QuotaEnforced" } } }, @@ -1756,6 +2051,14 @@ }, "node_affinities": { "$ref": "#/components/schemas/NodeAffinityList" + }, + "quota_enforced": { + "allOf": [ + { + "$ref": "#/components/schemas/QuotaEnforced" + } + ], + "default": false } }, "required": [ @@ -1774,7 +2077,8 @@ "memory": 2, "gpu": 0, "max_storage": 100, - "default_storage": 10 + "default_storage": 10, + "quota_enforced": false } }, "ResourceClassPatch": { @@ -1841,6 +2145,14 @@ }, "id": { "$ref": "#/components/schemas/IntegerId" + }, + "quota_enforced": { + "allOf": [ + { + "$ref": "#/components/schemas/QuotaEnforced" + } + ], + "default": false } }, "required": [ @@ -1861,6 +2173,7 @@ "gpu": 0, "max_storage": 100, "default_storage": 10, + "quota_enforced": false, "id": 1 } }, @@ -1917,6 +2230,7 @@ "gpu": 0, "max_storage": 100, "default_storage": 10, + "quota_enforced": true, "id": 1 }, { @@ -1927,6 +2241,7 @@ "gpu": 2, "max_storage": 10000, "default_storage": 10, + "quota_enforced": true, "id": 2 } ] @@ -1946,6 +2261,7 @@ "gpu": 0, "max_storage": 100, "default_storage": 10, + "quota_enforced": true, "id": 1 }, { @@ -1956,6 +2272,7 @@ "gpu": 2, "max_storage": 10000, "default_storage": 10, + "quota_enforced": true, "id": 2 } ] @@ -2381,6 +2698,205 @@ }, "uniqueItems": true }, + "PoolMemberUser": { + "type": "object", + "additionalProperties": false, + "required": ["member_type", "id", "role"], + "properties": { + "member_type": { + "type": "string", + "enum": ["user"] + }, + "id": { + "$ref": "#/components/schemas/UserId" + }, + "role": { + "type": "string", + "enum": ["viewer", "prohibited"] + } + }, + "example": { + "member_type": "user", + "id": "some-random-keycloak-id", + "role": "viewer" + } + }, + "PoolMemberGroup": { + "type": "object", + "additionalProperties": false, + "required": ["member_type", "id", "role"], + "properties": { + "member_type": { + "type": "string", + "enum": ["group"] + }, + "id": { + "$ref": "#/components/schemas/Ulid" + }, + "role": { + "type": "string", + "enum": ["group_viewer"] + } + }, + "example": { + "member_type": "group", + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "role": "group_viewer" + } + }, + "PoolMemberProject": { + "type": "object", + "additionalProperties": false, + "required": ["member_type", "id", "role"], + "properties": { + "member_type": { + "type": "string", + "enum": ["project"] + }, + "id": { + "$ref": "#/components/schemas/Ulid" + }, + "role": { + "type": "string", + "enum": ["project_viewer"] + } + }, + "example": { + "member_type": "project", + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAW", + "role": "project_viewer" + } + }, + "PoolMember": { + "oneOf": [ + { + "$ref": "#/components/schemas/PoolMemberUser" + }, + { + "$ref": "#/components/schemas/PoolMemberGroup" + }, + { + "$ref": "#/components/schemas/PoolMemberProject" + } + ] + }, + "PoolMembers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PoolMember" + }, + "uniqueItems": true + }, + "PoolMemberUserResponse": { + "type": "object", + "additionalProperties": false, + "required": ["member_type", "id", "role", "email"], + "properties": { + "member_type": { + "type": "string", + "enum": ["user"] + }, + "id": { + "$ref": "#/components/schemas/UserId" + }, + "role": { + "type": "string", + "enum": ["viewer", "prohibited"] + }, + "email": { + "type": "string" + } + }, + "example": { + "member_type": "user", + "id": "some-random-keycloak-id", + "role": "viewer", + "email": "user@example.com" + } + }, + "PoolMemberGroupResponse": { + "type": "object", + "additionalProperties": false, + "required": ["member_type", "id", "role", "slug", "name"], + "properties": { + "member_type": { + "type": "string", + "enum": ["group"] + }, + "id": { + "$ref": "#/components/schemas/Ulid" + }, + "role": { + "type": "string", + "enum": ["group_viewer"] + }, + "slug": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "example": { + "member_type": "group", + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "role": "group_viewer", + "slug": "my-group", + "name": "My Group" + } + }, + "PoolMemberProjectResponse": { + "type": "object", + "additionalProperties": false, + "required": ["member_type", "id", "role", "namespace", "name"], + "properties": { + "member_type": { + "type": "string", + "enum": ["project"] + }, + "id": { + "$ref": "#/components/schemas/Ulid" + }, + "role": { + "type": "string", + "enum": ["project_viewer"] + }, + "namespace": { + "type": "string", + "description": "Full project namespace path (e.g. user/project)" + }, + "name": { + "type": "string" + } + }, + "example": { + "member_type": "project", + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAW", + "role": "project_viewer", + "namespace": "user.doe/my-project", + "name": "My Project" + } + }, + "PoolMemberResponse": { + "oneOf": [ + { + "$ref": "#/components/schemas/PoolMemberUserResponse" + }, + { + "$ref": "#/components/schemas/PoolMemberGroupResponse" + }, + { + "$ref": "#/components/schemas/PoolMemberProjectResponse" + } + ] + }, + "PoolMembersResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PoolMemberResponse" + }, + "uniqueItems": true + }, "QuotaPatch": { "type": "object", "additionalProperties": false, @@ -2570,6 +3086,11 @@ "description": "A default selection for resource classes or resource pools", "example": false }, + "QuotaEnforced": { + "type": "boolean", + "description": "Whether sessions in this resource class should be hibernated when quota is exhausted", + "example": true + }, "PublicFlag": { "type": "boolean", "description": "A resource pool whose classes can be accessed by anyone", diff --git a/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts b/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts index 25b44f4247..df93af2f97 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; }; @@ -332,6 +330,8 @@ export type BuildParameters = { frontend_variant: FrontendVariant; repository_revision?: RepositoryRevision; context_dir?: BuildContextDir; + job_command?: EnvironmentCommand; + job_args?: EnvironmentArgs; }; export type EnvironmentImageSourceBuild = "build"; export type EnvironmentWithBuildGet = EnvironmentWithoutContainerImage & { @@ -350,7 +350,7 @@ export type EnvVar = { value?: string; }; export type EnvVariables = EnvVar[]; -export type LauncherType = "interactive" | "non_interactive"; +export type LauncherType = "interactive" | "non-interactive"; export type SessionLauncher = { id: Ulid; project_id: Ulid; @@ -400,6 +400,8 @@ export type BuildParametersPatch = { frontend_variant?: FrontendVariant; repository_revision?: RepositoryRevisionPatch; context_dir?: BuildContextDirPatch; + job_command?: EnvironmentCommand; + job_args?: EnvironmentArgs; }; export type EnvironmentPatchInLauncher = EnvironmentPatch & { environment_kind?: EnvironmentKind; diff --git a/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json b/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json index 6432495218..61e4525f24 100644 --- a/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json +++ b/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json @@ -870,7 +870,7 @@ }, "LauncherType": { "type": "string", - "enum": ["interactive", "non_interactive"] + "enum": ["interactive", "non-interactive"] }, "SessionLaunchersList": { "description": "A list of Renku session launchers", @@ -1144,6 +1144,12 @@ }, "context_dir": { "$ref": "#/components/schemas/BuildContextDir" + }, + "job_command": { + "$ref": "#/components/schemas/EnvironmentCommand" + }, + "job_args": { + "$ref": "#/components/schemas/EnvironmentArgs" } }, "required": ["repository", "builder_variant", "frontend_variant"] @@ -1185,6 +1191,12 @@ }, "context_dir": { "$ref": "#/components/schemas/BuildContextDirPatch" + }, + "job_command": { + "$ref": "#/components/schemas/EnvironmentCommand" + }, + "job_args": { + "$ref": "#/components/schemas/EnvironmentArgs" } } }, @@ -1192,7 +1204,7 @@ "description": "A container image", "type": "string", "maxLength": 500, - "pattern": "^[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*(\\/[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*)*(:[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}|@sha256:[a-fA-F0-9]{64}){0,1}$", + "minLength": 3, "example": "renku/renkulab-py:3.10-0.18.1" }, "Repository": { diff --git a/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts b/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts index b021e16c95..f378c90814 100644 --- a/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts +++ b/client/src/features/sessionsV2/api/sessionsV2.generated-api.ts @@ -12,9 +12,7 @@ const injectedRtkApi = api.injectEndpoints({ getSessions: build.query({ query: (queryArg) => ({ url: `/sessions`, - params: { - session_type: queryArg.sessionType, - }, + params: { session_type: queryArg.sessionType }, }), }), getSessionsBySessionId: build.query< @@ -48,9 +46,7 @@ const injectedRtkApi = api.injectEndpoints({ >({ query: (queryArg) => ({ url: `/sessions/${queryArg.sessionId}/logs`, - params: { - max_lines: queryArg.maxLines, - }, + params: { max_lines: queryArg.maxLines }, }), }), getSessionsImages: build.query< @@ -59,9 +55,7 @@ const injectedRtkApi = api.injectEndpoints({ >({ query: (queryArg) => ({ url: `/sessions/images`, - params: { - image_url: queryArg.imageUrl, - }, + params: { image_url: queryArg.imageUrl }, }), }), }), @@ -86,7 +80,8 @@ export type GetSessionsBySessionIdApiArg = { /** The id of the session */ sessionId: string; }; -export type DeleteSessionsBySessionIdApiResponse = unknown; +export type DeleteSessionsBySessionIdApiResponse = + /** status 204 The session was deleted or it never existed in the first place */ void; export type DeleteSessionsBySessionIdApiArg = { /** The id of the session that should be deleted */ sessionId: string; @@ -141,17 +136,23 @@ export type SessionStatus = { total_containers: number; }; export type Ulid = string; +export type SessionType = "interactive" | "non-interactive"; +export type SubmissionId = string; export type SessionResponse = { image: string; name: ServerName; resources: SessionResources; started: string | null; + job_completed_at?: string | null; lastInteraction?: string | null; status: SessionStatus; url: string; project_id: Ulid; launcher_id: Ulid; resource_class_id: number; + session_type: SessionType; + submission_id?: SubmissionId; + command_args?: string[] | null; }; export type ErrorResponse = { error: { @@ -188,11 +189,13 @@ export type SessionPostRequest = { /** The size of disk storage for the session, in gigabytes */ disk_storage?: number; resource_class_id?: number | null; + submission_id?: SubmissionId; data_connectors_overrides?: SessionDataConnectorsOverrideList; env_variable_overrides?: EnvVariableOverrides; + job_command_override?: string[] | null; + job_args_override?: string[] | null; }; export type SessionListResponse = SessionResponse[]; -export type SessionType = "interactive" | "non-interactive"; export type CurrentTime = "now"; export type SessionPatchRequest = { resource_class_id?: number; diff --git a/client/src/features/sessionsV2/api/sessionsV2.openapi.json b/client/src/features/sessionsV2/api/sessionsV2.openapi.json index 8dc9bde5cd..fe04e30b83 100644 --- a/client/src/features/sessionsV2/api/sessionsV2.openapi.json +++ b/client/src/features/sessionsV2/api/sessionsV2.openapi.json @@ -335,11 +335,28 @@ "nullable": true, "type": "integer" }, + "submission_id": { + "$ref": "#/components/schemas/SubmissionId" + }, "data_connectors_overrides": { "$ref": "#/components/schemas/SessionDataConnectorsOverrideList" }, "env_variable_overrides": { "$ref": "#/components/schemas/EnvVariableOverrides" + }, + "job_command_override": { + "nullable": true, + "type": "array", + "items": { + "type": "string" + } + }, + "job_args_override": { + "nullable": true, + "type": "array", + "items": { + "type": "string" + } } }, "required": ["launcher_id"], @@ -361,6 +378,11 @@ "nullable": true, "type": "string" }, + "job_completed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, "lastInteraction": { "type": "string", "format": "date-time", @@ -380,6 +402,20 @@ }, "resource_class_id": { "type": "integer" + }, + "session_type": { + "$ref": "#/components/schemas/SessionType" + }, + "submission_id": { + "$ref": "#/components/schemas/SubmissionId", + "nullable": true + }, + "command_args": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true } }, "required": [ @@ -391,7 +427,8 @@ "url", "project_id", "launcher_id", - "resource_class_id" + "resource_class_id", + "session_type" ], "type": "object" }, @@ -599,6 +636,11 @@ "pattern": "^[a-z]([-a-z0-9]*[a-z0-9])?$", "example": "d185e68d-d43-renku-2-b9ac279a4e8a85ac28d08" }, + "SubmissionId": { + "type": "string", + "pattern": "^[a-z][-0-9a-z]{3,19}$", + "description": "When submitting a job, i.e. the launcher used is a job\nlauncher, the submission id is required to deduplicate\nsame job submissions and allows retries.\n" + }, "ImageCheckResponse": { "type": "object", "properties": { diff --git a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx index bf5c4f4566..466f05b80f 100644 --- a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx +++ b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx @@ -57,6 +57,7 @@ import { usePatchSessionsBySessionIdMutation as usePatchSessionMutation, useDeleteSessionsBySessionIdMutation as useStopSessionMutation, } from "../../api/sessionsV2.api"; +import { sessionLauncherKindToCategory } from "../../session.utils"; import { SessionResources, SessionStatus, @@ -121,10 +122,7 @@ export default function ActiveSessionButton({ // TODO: fix react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect setIsResuming(false); - navigate(showSessionUrl); - // TODO: fix react-hooks/set-state-in-effect - - setIsResuming(false); + if (session.session_type === "interactive") navigate(showSessionUrl); } }, [ isResuming, @@ -132,6 +130,7 @@ export default function ActiveSessionButton({ isWaitingForResumedSession, navigate, showSessionUrl, + session.session_type, ]); useEffect(() => { if (errorResumeSession) { @@ -208,7 +207,7 @@ export default function ActiveSessionButton({ const [showModalStopSession, setShowModalStopSession] = useState(false); const toggleStopSession = useCallback( () => setShowModalStopSession((show) => !show), - [], + [] ); // Handle modifying session @@ -227,7 +226,7 @@ export default function ActiveSessionButton({ }); } }, - [modifySession, onResumeSession, session.name, session.status.state], + [modifySession, onResumeSession, session.name, session.status.state] ); useEffect(() => { if (errorModifySession) { @@ -241,19 +240,19 @@ export default function ActiveSessionButton({ const [showModalModifySession, setShowModalModifySession] = useState(false); const toggleModifySession = useCallback( () => setShowModalModifySession((show) => !show), - [], + [] ); const status = session.status.state; const failedScheduling = status === "failed" && (!!session.status.message?.includes( - "The resource quota has been exceeded.", + "The resource quota has been exceeded." ) || !!session.status.message?.includes( // TODO: fix spelling in notebooks // eslint-disable-next-line spellcheck/spell-checker - "Your session cannot be scheduled due to insufficent resources.", + "Your session cannot be scheduled due to insufficent resources." )); const buttonClassName = cx( @@ -263,48 +262,24 @@ export default function ActiveSessionButton({ "start-session-button", "py-1", "px-2", - "btn-outline-primary", + "btn-outline-primary" ); + const launcherCategory = sessionLauncherKindToCategory(session.session_type); + const defaultAction = - status === "stopping" || isStopping ? ( - - ) : isHibernating ? ( - - ) : status === "starting" ? ( - - - Open - - ) : status === "running" ? ( - <> - + ) : isHibernating ? ( + + ) : status === "starting" ? ( Open - - ) : status === "hibernated" ? ( - - ) : failedScheduling ? ( - <> + ) : status === "running" ? ( + <> + + + + Open + + + ) : status === "hibernated" ? ( - + + + ) : ( + <> + + + + ) + ) : launcherCategory === "job" ? ( + status === "stopping" || isStopping ? ( + - - ) : ( - <> + ) : isHibernating ? ( + + ) : status === "starting" ? ( + <> + + + ) : status === "running" ? ( + ) : status === "hibernated" ? ( - - ); + ) : failedScheduling ? ( + <> + + + ) : status === "succeeded" ? ( + + ) : ( + <> + + + ) + ) : null; const hibernateAction = status !== "stopping" && (status !== "failed" || failedScheduling) && @@ -407,6 +492,13 @@ export default function ActiveSessionButton({ ); + const dismissAction = launcherCategory === "job" && ( + + + Dismiss + + ); + const modifyAction = (status === "hibernated" || status === "failed") && !isStopping && !isHibernating && @@ -435,6 +527,27 @@ export default function ActiveSessionButton({ ); + if (launcherCategory === "job") { + return ( +
+ + {status === "succeeded" ? logsAction : dismissAction} + + +
+ ); + } + return (
{ diff --git a/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx b/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx index 60a5c269fe..d84e6d482d 100644 --- a/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx +++ b/client/src/features/sessionsV2/components/SessionForm/AdvancedSettingsFields.tsx @@ -35,9 +35,16 @@ import { SessionEnvironmentForm } from "../../../admin/SessionEnvironmentFormCon import { DEFAULT_URL, ENVIRONMENT_VALUES_DESCRIPTION, + JOB_COMMAND_VALIDATION_MESSAGE, } from "../../session.constants"; -import { isValidJSONStringArray } from "../../session.utils"; -import { SessionLauncherForm } from "../../sessionsV2.types"; +import { + isValidJSONStringArray, + isValidRequiredJSONStringArray, +} from "../../session.utils"; +import { + SessionLauncherForm, + type LauncherCategory, +} from "../../sessionsV2.types"; function OptionalLabel() { return (Optional); @@ -206,7 +213,7 @@ function CheckboxOrRadioFormField({ ); } -interface JsonFieldProps { +export interface JsonFieldProps { control: Control; name: Path; label: string; @@ -214,17 +221,34 @@ interface JsonFieldProps { errors?: FieldErrors; helpText: string; isOptional?: boolean; + dataCy?: string; + disabled?: boolean; } -function JsonField({ +export function JsonField({ control, name, label, info, errors, helpText, - isOptional, + isOptional = true, + dataCy, + disabled = false, }: JsonFieldProps) { + const rules = isOptional + ? { + validate: (value: string) => isValidJSONStringArray(value?.toString()), + } + : { + validate: (value: string) => + isValidRequiredJSONStringArray( + value?.toString(), + JOB_COMMAND_VALIDATION_MESSAGE.required, + JOB_COMMAND_VALIDATION_MESSAGE.empty + ), + }; + return ( <> ({ isValidJSONStringArray(value?.toString()), + rules={rules} + render={({ field, fieldState: { error: fieldError } }) => { + const error = errors?.[name] ?? fieldError; + return ( + <> +