From 3286c168a84e1c5b7d72ff0aba8e3afa932757db Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Thu, 19 Mar 2026 11:07:01 +0100 Subject: [PATCH 1/5] feat: present usage quota information to users (#4083) * build: update computeResources api * feat: present usage quota information at session launch time * feat: show usage quota information when viewing session launcher details * feat: show usage quota for running or paused sessions * feat: show usage quota during session start * feat: prevent resume if usage quota is used --- .../src/components/progress/ProgressSteps.tsx | 11 +- .../dashboardV2/DashboardV2Sessions.tsx | 17 + .../sessionsV2/SessionList/SessionCard.tsx | 18 + .../SessionList/SessionLauncherCard.tsx | 29 +- .../SessionShowPage/ShowSessionPage.tsx | 4 + .../StartSessionProgressBar.tsx | 13 +- .../features/sessionsV2/SessionStartPage.tsx | 112 ++++-- .../sessionsV2/SessionView/SessionView.tsx | 28 ++ .../sessionsV2/StartSessionButton.tsx | 105 +++-- .../api/computeResources.generated-api.ts | 8 +- .../api/computeResources.openapi.json | 53 ++- .../SessionButton/ActiveSessionButton.tsx | 375 +++++++++++------- .../components/SessionClassSelector.tsx | 38 +- .../components/SessionLauncherButtons.tsx | 55 ++- .../SessionModals/SelectResourceClass.tsx | 12 + .../SessionStatus/SessionStatus.tsx | 145 ++++--- .../sessionsV2/components/SessionsList.tsx | 52 ++- .../{session.utils.ts => session.utils.tsx} | 38 ++ .../features/sessionsV2/sessionsV2.types.ts | 10 +- tests/cypress/e2e/projectV2Session.spec.ts | 166 +++++++- .../dataServices/resource-pools-consumed.json | 112 ++++++ .../fixtures/dataServices/resource-pools.json | 9 +- .../fixtures/sessions/sessionsV2Paused.json | 22 + .../support/renkulab-fixtures/sessions.ts | 21 +- 24 files changed, 1124 insertions(+), 329 deletions(-) rename client/src/features/sessionsV2/{session.utils.ts => session.utils.tsx} (93%) create mode 100644 tests/cypress/fixtures/dataServices/resource-pools-consumed.json create mode 100644 tests/cypress/fixtures/sessions/sessionsV2Paused.json diff --git a/client/src/components/progress/ProgressSteps.tsx b/client/src/components/progress/ProgressSteps.tsx index 1af698ce17..9439c46f97 100644 --- a/client/src/components/progress/ProgressSteps.tsx +++ b/client/src/components/progress/ProgressSteps.tsx @@ -71,16 +71,18 @@ interface ProgressStepsIndicatorProps { style: ProgressStyle; title: string; description: string; - status: StepsProgressBar[]; moreOptions?: React.ReactNode; + extraDescription?: React.ReactNode; + status: StepsProgressBar[]; } export default function ProgressStepsIndicator({ style = ProgressStyle.Dark, title, description, - status, moreOptions, + extraDescription, + status, }: ProgressStepsIndicatorProps) { const content = status.map((s) => ( @@ -88,8 +90,9 @@ export default function ProgressStepsIndicator({ return (

{title}

-

{description}

-
+

{description}

+ {extraDescription} +
{content} {moreOptions}
diff --git a/client/src/features/dashboardV2/DashboardV2Sessions.tsx b/client/src/features/dashboardV2/DashboardV2Sessions.tsx index cad92358f8..6ce54ee069 100644 --- a/client/src/features/dashboardV2/DashboardV2Sessions.tsx +++ b/client/src/features/dashboardV2/DashboardV2Sessions.tsx @@ -19,6 +19,7 @@ import { SerializedError } from "@reduxjs/toolkit"; import { FetchBaseQueryError, skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; +import { useMemo } from "react"; import { generatePath, Link } from "react-router"; import { Col, ListGroup, Row } from "reactstrap"; @@ -26,6 +27,7 @@ import RtkOrDataServicesError from "../../components/errors/RtkOrDataServicesErr import { Loader } from "../../components/Loader"; import { ABSOLUTE_ROUTES } from "../../routing/routes.constants"; import { useGetProjectsByProjectIdQuery } from "../projectsV2/api/projectV2.enhanced-api"; +import { useGetResourcePoolsQuery } from "../sessionsV2/api/computeResources.api"; import { useGetSessionLaunchersByLauncherIdQuery as useGetProjectSessionLauncherQuery } from "../sessionsV2/api/sessionLaunchersV2.api"; import ActiveSessionButton from "../sessionsV2/components/SessionButton/ActiveSessionButton"; import { @@ -138,6 +140,17 @@ function DashboardSession({ session }: DashboardSessionProps) { const sessionStyles = getSessionStatusStyles(session); const state = session.status.state; + const { data: resourcePools } = useGetResourcePoolsQuery( + session ? {} : skipToken + ); + const currentSessionClassId = session?.resource_class_id; + const userLauncherClass = useMemo( + () => + resourcePools + ?.flatMap((pool) => pool.classes) + .find((c) => c.id == currentSessionClassId), + [currentSessionClassId, resourcePools] + ); return (
diff --git a/client/src/features/sessionsV2/SessionList/SessionCard.tsx b/client/src/features/sessionsV2/SessionList/SessionCard.tsx index eb8a767673..a8fbbef1e3 100644 --- a/client/src/features/sessionsV2/SessionList/SessionCard.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionCard.tsx @@ -16,10 +16,13 @@ * limitations under the License. */ +import { skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; +import { useMemo } from "react"; import { Col, Row } from "reactstrap"; import { Project } from "../../projectsV2/api/projectV2.api"; +import { useGetResourcePoolsQuery } from "../api/computeResources.api"; import ActiveSessionButton from "../components/SessionButton/ActiveSessionButton"; import { getSessionStatusStyles, @@ -36,6 +39,17 @@ interface SessionCardProps { session?: SessionV2; } export default function SessionCard({ project, session }: SessionCardProps) { + const { data: resourcePools } = useGetResourcePoolsQuery( + session ? {} : skipToken + ); + const currentSessionClassId = session?.resource_class_id; + const userLauncherClass = useMemo( + () => + resourcePools + ?.flatMap((pool) => pool.classes) + .find((c) => c.id == currentSessionClassId), + [currentSessionClassId, resourcePools] + ); if (!session) return null; const stylesPerSession = getSessionStatusStyles(session); @@ -99,6 +113,10 @@ export default function SessionCard({ project, session }: SessionCardProps) { >
build.status === "succeeded" && build.id !== lastBuild?.id, + (build) => build.status === "succeeded" && build.id !== lastBuild?.id ); const hasSession = !!sessions?.length; @@ -101,7 +101,7 @@ export default function SessionLauncherCard({ : skipToken, { pollingInterval: 1_000, - }, + } ); const otherLauncherActions = launcher && @@ -132,20 +132,24 @@ export default function SessionLauncherCard({ useGetSessionsImagesQuery( environment?.container_image != null ? { imageUrl: environment.container_image } - : skipToken, + : skipToken ); const { data: resourcePools, isLoading: isLoadingResourcePools } = computeResourcesApi.endpoints.getResourcePools.useQueryState({}); // Ref: https://github.com/facebook/react/issues/35577 // eslint-disable-next-line react-hooks/preserve-manual-memoization - const resourcePool = useMemo(() => { + const { resourcePool, resourceClass } = useMemo(() => { if (launcher?.resource_class_id == null || resourcePools == null) { - return undefined; + return { resourcePool: undefined, resourceClass: undefined }; } - return resourcePools.find(({ classes }) => - classes.some(({ id }) => id === launcher.resource_class_id), + const resourcePool = resourcePools.find(({ classes }) => + classes.some(({ id }) => id === launcher.resource_class_id) + ); + const resourceClass = resourcePool?.classes.find( + ({ id }) => id === launcher.resource_class_id ); + return { resourcePool, resourceClass }; }, [launcher?.resource_class_id, resourcePools]); return ( @@ -154,7 +158,7 @@ export default function SessionLauncherCard({ styles.SessionLauncherCard, "cursor-pointer", "shadow-none", - "rounded-0", + "rounded-0" )} data-cy="session-launcher-item" onClick={toggleSessionView} @@ -266,8 +270,8 @@ export default function SessionLauncherCard({ lastBuild?.status === "succeeded" ? lastBuild?.result?.completed_at : lastSuccessfulBuild?.status === "succeeded" - ? lastSuccessfulBuild?.result?.completed_at - : undefined + ? lastSuccessfulBuild?.result?.completed_at + : undefined } /> @@ -292,7 +296,7 @@ export default function SessionLauncherCard({ "d-flex", "flex-column", "align-items-end", - "gap-2", + "gap-2" )} >
pool.classes) + .find((cls) => cls.id === session?.resource_class_id); const statusData = session?.status; const description = statusData?.ready_containers != null && @@ -49,8 +55,11 @@ export function StartSessionProgressBarV2({ >

Launching Session

-

Starting session services

-
+

Starting session services

+ {resourceClass?.usage_available != null && ( + + )} +
{description}
diff --git a/client/src/features/sessionsV2/SessionStartPage.tsx b/client/src/features/sessionsV2/SessionStartPage.tsx index a837699e4c..ce0c1bb6f5 100644 --- a/client/src/features/sessionsV2/SessionStartPage.tsx +++ b/client/src/features/sessionsV2/SessionStartPage.tsx @@ -48,6 +48,7 @@ import type { SessionSecretSlotWithSecret } from "../ProjectPageV2/ProjectPageCo import type { Project } from "../projectsV2/api/projectV2.api"; import { useGetNamespacesByNamespaceProjectsAndSlugQuery } from "../projectsV2/api/projectV2.enhanced-api"; import { storageSecretNameToFieldName } from "../secretsV2/secrets.utils"; +import { useGetResourcePoolsQuery } from "./api/computeResources.generated-api"; import type { SessionLauncher } from "./api/sessionLaunchersV2.api"; import { useGetProjectsByProjectIdSessionLaunchersQuery as useGetProjectSessionLaunchersQuery } from "./api/sessionLaunchersV2.api"; import { @@ -57,7 +58,7 @@ import { import { SelectResourceClassModal } from "./components/SessionModals/SelectResourceClass"; import DataConnectorSecretsModal from "./DataConnectorSecretsModal"; import { CUSTOM_LAUNCH_SEARCH_PARAM } from "./session.constants"; -import { validateEnvVariableName } from "./session.utils"; +import { UsageAvailable, validateEnvVariableName } from "./session.utils"; import SessionImageModal from "./SessionImageModal"; import SessionRepositoriesModal from "./SessionRepositoriesModal"; import SessionSecretsModal from "./SessionSecretsModal"; @@ -70,10 +71,8 @@ import useSessionLaunchState from "./useSessionLaunchState.hook"; import progressBoxStyles from "~/components/progress/ProgressBox.module.scss"; -interface SaveCloudStorageProps extends Omit< - StartSessionFromLauncherProps, - "project" -> { +interface SaveCloudStorageProps + extends Omit { startSessionOptionsV2: StartSessionOptionsV2; } @@ -99,7 +98,7 @@ function SaveCloudStorage({ }, [startSessionOptionsV2.dataConnectors]); const [results, setResults] = useState( - credentialsToSave.map(() => StatusStepProgressBar.WAITING), + credentialsToSave.map(() => StatusStepProgressBar.WAITING) ); const [index, setIndex] = useState(0); @@ -133,7 +132,7 @@ function SaveCloudStorage({ ([key, value]) => ({ name: key, value, - }), + }) ), }); }, [credentialsToSave, index, saveCredentials]); @@ -175,13 +174,13 @@ function SaveCloudStorage({ } if (index >= credentialsToSave.length) { const cloudStorageConfigs = startSessionOptionsV2.dataConnectors?.map( - (cs) => storageDefinitionAfterSavingCredentialsFromConfig(cs), + (cs) => storageDefinitionAfterSavingCredentialsFromConfig(cs) ); if (cloudStorageConfigs) dispatch( startSessionOptionsV2Slice.actions.setDataConnectorsOverrides( - cloudStorageConfigs, - ), + cloudStorageConfigs + ) ); } }, [ @@ -196,7 +195,7 @@ function SaveCloudStorage({
startSessionOptionsV2, + ({ startSessionOptionsV2 }) => startSessionOptionsV2 ); - const [ startSessionV2, { data: session, error: error, isLoading: isLoadingStartSession, isError }, @@ -230,7 +228,7 @@ function SessionStarting({ launcher, project }: StartSessionFromLauncherProps) { disk_storage: startSessionOptionsV2.storage, resource_class_id: startSessionOptionsV2.sessionClass, data_connectors_overrides: startSessionOptionsV2.dataConnectors?.flatMap( - dataConnectorsOverrideFromConfig, + dataConnectorsOverrideFromConfig ), env_variable_overrides: Array.from(searchParams) .filter(([name]) => validateEnvVariableName(name) === true) @@ -297,13 +295,18 @@ function SessionStarting({ launcher, project }: StartSessionFromLauncherProps) { status: error ? StatusStepProgressBar.FAILED : isLoadingStartSession - ? StatusStepProgressBar.EXECUTING - : StatusStepProgressBar.READY, + ? StatusStepProgressBar.EXECUTING + : StatusStepProgressBar.READY, step: "Requesting session", }, ]); }, [error, isLoadingStartSession, startSessionOptionsV2]); + const { data: resourcePools } = useGetResourcePoolsQuery({}); + const resourceClass = resourcePools + ?.flatMap((pool) => pool.classes) + .find((cls) => cls.id === startSessionOptionsV2.sessionClass); + return (
{error && } @@ -311,7 +314,7 @@ function SessionStarting({ launcher, project }: StartSessionFromLauncherProps) {
+ ) + } />
@@ -327,7 +337,7 @@ function SessionStarting({ launcher, project }: StartSessionFromLauncherProps) { } function doesCloudStorageNeedCredentials( - config: SessionStartDataConnectorConfiguration, + config: SessionStartDataConnectorConfiguration ) { if (!config.active || config.skip) { return false; @@ -339,24 +349,25 @@ function doesCloudStorageNeedCredentials( config.savedCredentialFields?.map((field) => [ storageSecretNameToFieldName({ name: field }), true, - ]), + ]) ) : {}; if (sensitiveFields.every((key) => credentialFieldDict[key] != null)) { return false; } return Object.values(config.sensitiveFieldValues).some( - (value) => value === "", + (value) => value === "" ); } function shouldCloudStorageSaveCredentials( - config: SessionStartDataConnectorConfiguration, + config: SessionStartDataConnectorConfiguration ) { return config.saveCredentials; } -interface StartSessionWithCloudStorageModalProps extends StartSessionFromLauncherProps { +interface StartSessionWithCloudStorageModalProps + extends StartSessionFromLauncherProps { dataConnectors: SessionStartDataConnectorConfiguration[]; } @@ -372,17 +383,17 @@ function StartSessionWithCloudStorageModal({ const configsWithCredentials = useMemo( () => dataConnectors.filter( - (config) => !doesCloudStorageNeedCredentials(config), + (config) => !doesCloudStorageNeedCredentials(config) ), - [dataConnectors], + [dataConnectors] ); const configsNeedingCredentials = useMemo( () => dataConnectors.filter((config) => - doesCloudStorageNeedCredentials(config), + doesCloudStorageNeedCredentials(config) ), - [dataConnectors], + [dataConnectors] ); useEffect(() => { @@ -402,11 +413,11 @@ function StartSessionWithCloudStorageModal({ ]; dispatch( startSessionOptionsV2Slice.actions.setDataConnectorsOverrides( - cloudStorageConfigs, - ), + cloudStorageConfigs + ) ); }, - [dispatch, configsWithCredentials], + [dispatch, configsWithCredentials] ); const steps = [ @@ -436,7 +447,7 @@ function StartSessionWithCloudStorageModal({
startSessionOptionsV2, + ({ startSessionOptionsV2 }) => startSessionOptionsV2 ); const { @@ -499,11 +510,11 @@ function StartSessionFromLauncher({ }); const needsCredentials = startSessionOptionsV2.dataConnectors?.some( - doesCloudStorageNeedCredentials, + doesCloudStorageNeedCredentials ); const shouldSaveCredentials = startSessionOptionsV2.dataConnectors?.some( - shouldCloudStorageSaveCredentials, + shouldCloudStorageSaveCredentials ); const allDataFetched = @@ -572,6 +583,11 @@ function StartSessionFromLauncher({ startSessionOptionsV2.userSecretsReady, ]); + const { data: resourcePools } = useGetResourcePoolsQuery({}); + const resourceClass = resourcePools + ?.flatMap((pool) => pool.classes) + .find((cls) => cls.id === startSessionOptionsV2.sessionClass); + const steps = [ { id: 0, @@ -643,7 +659,7 @@ function StartSessionFromLauncher({
+ ) + } /> launchers?.find(({ id }) => id === launcherId), - [launcherId, launchers], + [launcherId, launchers] ); //? We do not start the session while the logged out prompt is displayed. const { isLoggedIn, shouldBeLoggedIn } = useAppSelector( - ({ loginState }) => loginState, + ({ loginState }) => loginState ); const isShowingLoggedOutPrompt = !isLoggedIn && shouldBeLoggedIn; @@ -717,7 +740,8 @@ export default function SessionStartPage() { return ; } -interface StartSessionWithSessionSecretsModalProps extends StartSessionFromLauncherProps { +interface StartSessionWithSessionSecretsModalProps + extends StartSessionFromLauncherProps { sessionSecretSlotsWithSecrets: SessionSecretSlotWithSecret[]; } @@ -727,7 +751,7 @@ function StartSessionWithSessionSecretsModal({ sessionSecretSlotsWithSecrets, }: StartSessionWithSessionSecretsModalProps) { const startSessionOptionsV2 = useAppSelector( - ({ startSessionOptionsV2 }) => startSessionOptionsV2, + ({ startSessionOptionsV2 }) => startSessionOptionsV2 ); const showModal = !startSessionOptionsV2.userSecretsReady; @@ -750,7 +774,7 @@ function StartSessionWithSessionSecretsModal({
startSessionOptionsV2, + ({ startSessionOptionsV2 }) => startSessionOptionsV2 ); const showModal = !startSessionOptionsV2.imageReady; @@ -798,7 +822,7 @@ function StartSessionImageModal({
startSessionOptionsV2, + ({ startSessionOptionsV2 }) => startSessionOptionsV2 ); const showModal = !startSessionOptionsV2.repositoriesReady; @@ -846,7 +870,7 @@ function StartSessionRepositoriesModal({
+ resourcePools + ?.flatMap((pool) => pool.classes) + .find((c) => c.id == currentSessionClassId), + [currentSessionClassId, resourcePools] + ); + const quotaEnforced = false; // TODO: Pass the actual value when available from the API return ( } contentSession={ @@ -131,6 +146,11 @@ function SessionCard({ contentResources={ } /> @@ -140,9 +160,11 @@ function SessionCard({ function SessionCardNotRunning({ launcher, project, + resourceClass, }: { launcher: SessionLauncher; project: Project; + resourceClass: ResourceClassWithIdFiltered | undefined; }) { return (
@@ -303,6 +326,10 @@ export function SessionView({ launcher?.disk_storage ?? launcherResourceClass.default_storage, gpu: launcherResourceClass.gpu, }} + usageLimit={{ + resourceClass: userLauncherResourceClass, + quotaEnforced: false, // TODO: Pass the actual value when available from the API + }} /> ) : (

This session launcher does not have a default resource class.

@@ -368,6 +395,7 @@ export function SessionView({ )}
diff --git a/client/src/features/sessionsV2/StartSessionButton.tsx b/client/src/features/sessionsV2/StartSessionButton.tsx index 7e24b34507..c02726bda0 100644 --- a/client/src/features/sessionsV2/StartSessionButton.tsx +++ b/client/src/features/sessionsV2/StartSessionButton.tsx @@ -28,14 +28,71 @@ import AppContext from "~/utils/context/appContext"; import { DEFAULT_APP_PARAMS } from "~/utils/context/appParams.constants"; import { ButtonWithMenuV2 } from "../../components/buttons/Button"; import { ABSOLUTE_ROUTES } from "../../routing/routes.constants"; +import { ResourceClassWithIdFiltered } from "./api/computeResources.generated-api"; import { SessionLauncher } from "./api/sessionLaunchersV2.generated-api"; import { useGetSessionsImagesQuery } from "./api/sessionsV2.api"; +import { UsageQuotaReachedLaunchButton } from "./components/SessionLauncherButtons"; import { CUSTOM_LAUNCH_SEARCH_PARAM } from "./session.constants"; +interface SessionStartDefaultActionButtonProps + extends Pick { + force: boolean; + isLaunchButtonDisabled: boolean; + startUrl: string; +} + +function SessionStartDefaultActionButton({ + force, + isLaunchButtonDisabled, + launcher, + resourceClass, + startUrl, +}: SessionStartDefaultActionButtonProps) { + const launchButtonDisableReason = + "No image available. Run the Build action to generate an image."; + + if (resourceClass) { + if ( + resourceClass.usage_available != null && + resourceClass.usage_available <= 0 + ) { + return ; + } + } + + return ( + + + + {force ? "Force launch" : "Launch"} + + {isLaunchButtonDisabled && ( + + {launchButtonDisableReason} + + )} + + ); +} + interface StartSessionButtonProps { namespace: string; slug: string; launcher: SessionLauncher; + resourceClass: ResourceClassWithIdFiltered | undefined; disabled?: boolean; useOldImage?: boolean; otherActions?: ReactNode; @@ -46,6 +103,7 @@ export default function StartSessionButton({ launcher, namespace, slug, + resourceClass, }: StartSessionButtonProps) { const startUrl = generatePath( ABSOLUTE_ROUTES.v2.projects.show.sessions.start, @@ -53,7 +111,7 @@ export default function StartSessionButton({ launcherId: launcher.id, namespace, slug, - }, + } ); const environment = launcher?.environment; const isExternalImageEnvironment = @@ -64,7 +122,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,46 +130,17 @@ export default function StartSessionButton({ const { data: builds } = useGetBuildsQuery( imageBuildersEnabled && environment.environment_image_source === "build" ? { environmentId: environment.id } - : skipToken, + : skipToken ); const hasSuccessfulBuild = builds?.find( - (build) => build.status === "succeeded", + (build) => build.status === "succeeded" ); const force = isExternalImageEnvironment && !isLoading && !data?.accessible; const isLaunchButtonDisabled = environment.environment_image_source === "build" && !hasSuccessfulBuild; - const launchButtonDisableReason = - "No image available. Run the Build action to generate an image."; - - const launchAction = ( - - - - {force ? "Force launch" : "Launch"} - - {isLaunchButtonDisabled && ( - - {launchButtonDisableReason} - - )} - - ); const customizeLaunch = ( + } preventPropagation size="sm" disabled={isLaunchButtonDisabled} diff --git a/client/src/features/sessionsV2/api/computeResources.generated-api.ts b/client/src/features/sessionsV2/api/computeResources.generated-api.ts index 98c48a1239..af04584cb2 100644 --- a/client/src/features/sessionsV2/api/computeResources.generated-api.ts +++ b/client/src/features/sessionsV2/api/computeResources.generated-api.ts @@ -484,7 +484,7 @@ export type GetResourcePoolsByResourcePoolIdUsersAndUserIdApiArg = { userId: string; }; export type DeleteResourcePoolsByResourcePoolIdUsersAndUserIdApiResponse = - /** status 204 The user was removed or it was not part of the pool */ void; + /** status 204 The user was removed, or it was not part of the pool */ void; export type DeleteResourcePoolsByResourcePoolIdUsersAndUserIdApiArg = { resourcePoolId: number; userId: string; @@ -629,6 +629,8 @@ export type QuotaWithId = { gpu: Gpu; id: Name; }; +export type UsageAvailable = number; +export type UsageLimitTotal = number; export type ResourceClassWithIdFiltered = { name: Name; cpu: Cpu; @@ -641,6 +643,8 @@ export type ResourceClassWithIdFiltered = { matching?: boolean; tolerations?: K8SLabelList; node_affinities?: NodeAffinityList; + usage_available?: UsageAvailable; + usage_limit_total?: UsageLimitTotal; }; export type PublicFlag = boolean; export type RemoteConfigurationFirecrestProviderId = string; @@ -669,6 +673,7 @@ export type IdleThreshold = number; export type HibernationThreshold = number; export type HibernationWarningPeriod = number; export type RuntimePlatform = "linux/amd64" | "linux/arm64"; +export type ResourceUsage = number; export type ResourcePoolWithIdFiltered = { quota?: QuotaWithId; classes: ResourceClassWithIdFiltered[]; @@ -682,6 +687,7 @@ export type ResourcePoolWithIdFiltered = { hibernation_warning_period?: HibernationWarningPeriod; cluster_id?: Ulid; platform: RuntimePlatform; + resource_usage?: ResourceUsage; }; export type ResourcePoolsWithIdFiltered = ResourcePoolWithIdFiltered[]; export type CpuFilter = number; diff --git a/client/src/features/sessionsV2/api/computeResources.openapi.json b/client/src/features/sessionsV2/api/computeResources.openapi.json index 13bd95b027..1da61b803f 100644 --- a/client/src/features/sessionsV2/api/computeResources.openapi.json +++ b/client/src/features/sessionsV2/api/computeResources.openapi.json @@ -1189,7 +1189,7 @@ ], "responses": { "204": { - "description": "The user was removed or it was not part of the pool" + "description": "The user was removed, or it was not part of the pool" }, "default": { "$ref": "#/components/responses/Error" @@ -1898,6 +1898,12 @@ }, "node_affinities": { "$ref": "#/components/schemas/NodeAffinityList" + }, + "usage_available": { + "$ref": "#/components/schemas/UsageAvailable" + }, + "usage_limit_total": { + "$ref": "#/components/schemas/UsageLimitTotal" } }, "required": [ @@ -2328,6 +2334,9 @@ }, "platform": { "$ref": "#/components/schemas/RuntimePlatform" + }, + "resource_usage": { + "$ref": "#/components/schemas/ResourceUsage" } }, "required": ["classes", "name", "id", "public", "default", "platform"], @@ -2546,6 +2555,28 @@ "minimum": 0, "maximum": 9223372036854776000 }, + "UsageAvailable": { + "type": "number", + "format": "double", + "description": "Amount of a resource available (in hours)", + "example": 3.141, + "minimum": 0 + }, + "UsageLimitTotal": { + "type": "number", + "format": "float", + "description": "Total number of a resources hours available to the user", + "example": 10, + "minimum": 0, + "default": null + }, + "ResourceUsage": { + "type": "number", + "format": "double", + "description": "Amount of a resource used (in credits)", + "example": 3.141, + "minimum": 0 + }, "Storage": { "type": "integer", "description": "Number of gigabytes of storage", @@ -2953,6 +2984,24 @@ } } } + }, + "securitySchemes": { + "bearer": { + "scheme": "bearer", + "type": "http" + }, + "oidc": { + "type": "openIdConnect", + "openIdConnectUrl": "/auth/realms/Renku/.well-known/openid-configuration" + } + } + }, + "security": [ + { + "bearer": [] + }, + { + "oidc": ["openid"] } - } + ] } diff --git a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx index bf5c4f4566..4839ad4696 100644 --- a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx +++ b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx @@ -17,7 +17,7 @@ */ import cx from "classnames"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ArrowRightCircle, BoxArrowUpRight, @@ -40,6 +40,7 @@ import { ModalFooter, ModalHeader, Row, + UncontrolledTooltip, } from "reactstrap"; import { WarnAlert } from "~/components/Alert"; @@ -57,7 +58,8 @@ import { usePatchSessionsBySessionIdMutation as usePatchSessionMutation, useDeleteSessionsBySessionIdMutation as useStopSessionMutation, } from "../../api/sessionsV2.api"; -import { +import type { + SessionLauncherResourceUsageLimit, SessionResources, SessionStatus, SessionStatusState, @@ -72,17 +74,220 @@ import { import ShutdownSessionContent from "../SessionModals/ShoutdownSessionContent"; import { SessionRowResourceRequests } from "../SessionsList"; +interface ActiveSessionDefaultButtonProps + extends Pick< + ActiveSessionButtonProps, + "usageLimit" | "session" | "showSessionUrl" + > { + isHibernating: boolean; + isResuming: boolean; + isStopping: boolean; + onHibernateSession: () => void; + onResumeSession: () => void; + onStopSession: () => void; + toggleLogsModal: () => void; + toggleModifySession: () => void; +} +function ActiveSessionDefaultButton({ + isHibernating, + isResuming, + isStopping, + onHibernateSession, + onResumeSession, + onStopSession, + usageLimit, + session, + showSessionUrl, + toggleLogsModal, + toggleModifySession, +}: ActiveSessionDefaultButtonProps) { + const status = session.status.state; + const failedScheduling = + status === "failed" && + (!!session.status.message?.includes( + "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." + )); + const buttonClassName = cx( + "btn", + "btn-rk-green", + "btn-icon-text", + "start-session-button", + "py-1", + "px-2", + "btn-outline-primary" + ); + const { data: user } = useGetUserQueryState(); + const isUserLoggedIn = !!user?.isLoggedIn; + if (status === "stopping" || isStopping) + return ( + + ); + if (isHibernating) + return ( + + ); + if (status === "starting") + return ( + + + Open + + ); + if (status === "running") + return ( + <> + + + + Open + + + ); + if (status === "hibernated") { + if ( + usageLimit.quotaEnforced && + usageLimit.resourceClass?.usage_available != null && + usageLimit.resourceClass.usage_available <= 0 + ) { + return ( + <> + + Please modify the session to use a different resource class. The + quota for this resource pool has been fully used. + + + + + + ); + } + return ( + + ); + } + + if (failedScheduling) return; + <> + + + ; + return ( + <> + + + + ); +} + interface ActiveSessionButtonProps { className?: string; + usageLimit: SessionLauncherResourceUsageLimit; session: SessionV2; showSessionUrl: string; - toggleSessionDetails?: () => void; } export default function ActiveSessionButton({ + className, + usageLimit, session, showSessionUrl, - className, }: ActiveSessionButtonProps) { const { renkuToastDanger } = useRenkuToast(); @@ -208,7 +413,7 @@ export default function ActiveSessionButton({ const [showModalStopSession, setShowModalStopSession] = useState(false); const toggleStopSession = useCallback( () => setShowModalStopSession((show) => !show), - [], + [] ); // Handle modifying session @@ -227,7 +432,7 @@ export default function ActiveSessionButton({ }); } }, - [modifySession, onResumeSession, session.name, session.status.state], + [modifySession, onResumeSession, session.name, session.status.state] ); useEffect(() => { if (errorModifySession) { @@ -241,147 +446,37 @@ 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( - "btn", - "btn-rk-green", - "btn-icon-text", - "start-session-button", - "py-1", - "px-2", - "btn-outline-primary", + const defaultAction = ( + ); - const defaultAction = - status === "stopping" || isStopping ? ( - - ) : isHibernating ? ( - - ) : status === "starting" ? ( - - - Open - - ) : status === "running" ? ( - <> - - - - Open - - - ) : status === "hibernated" ? ( - - ) : failedScheduling ? ( - <> - - - - ) : ( - <> - - - - ); - const hibernateAction = status !== "stopping" && (status !== "failed" || failedScheduling) && status !== "hibernated" && @@ -617,7 +712,7 @@ function ModifySessionModalContent({ toggleModal(); }; }, - [currentSessionClass, onModifySession, toggleModal], + [currentSessionClass, onModifySession, toggleModal] ); useEffect(() => { @@ -656,6 +751,14 @@ function ModifySessionModalContent({ /> ); + const userLauncherClass = useMemo( + () => + resourcePools + ?.flatMap((pool) => pool.classes) + .find((c) => c.id == currentSessionClass?.id), + [currentSessionClass, resourcePools] + ); + return ( <> @@ -667,6 +770,10 @@ function ModifySessionModalContent({
diff --git a/client/src/features/sessionsV2/components/SessionClassSelector.tsx b/client/src/features/sessionsV2/components/SessionClassSelector.tsx index 9d9d7a86fd..0a8bc98864 100644 --- a/client/src/features/sessionsV2/components/SessionClassSelector.tsx +++ b/client/src/features/sessionsV2/components/SessionClassSelector.tsx @@ -42,6 +42,7 @@ import { } from "~/features/sessionsV2/api/computeResources.api"; import { useGetNotebooksVersionQuery } from "~/features/versions/versions.api"; import { toHumanDuration } from "~/utils/helpers/DurationUtils"; +import { usageAvailableString } from "../session.utils"; import styles from "./SessionClassSelector.module.scss"; @@ -66,13 +67,13 @@ function SessionClassThresholds({ idleThreshold: pool.idle_threshold ?? defaultIdle ?? 0, hibernationThreshold: pool.hibernation_threshold ?? defaultHibernation ?? 0, - })), + })) ); }, [defaultHibernation, defaultIdle, resourcePools]); const currentClassThresholds = useMemo( () => classesThresholds.find((c) => c.classId === currentSessionClass?.id), - [classesThresholds, currentSessionClass], + [classesThresholds, currentSessionClass] ); if ( @@ -117,7 +118,7 @@ interface OptionGroup extends GroupBase { const makeGroupedOptions = ( resourcePools: ResourcePoolWithIdFiltered[], - defaultIdleThreshold?: number, + defaultIdleThreshold?: number ): OptionGroup[] => resourcePools.map((pool) => ({ label: pool.name, @@ -144,9 +145,9 @@ const SessionClassSelector = ({ () => makeGroupedOptions( resourcePools, - nbVersion?.defaultCullingThresholds?.registered.idle, + nbVersion?.defaultCullingThresholds?.registered.idle ), - [resourcePools, nbVersion], + [resourcePools, nbVersion] ); return ( @@ -191,7 +192,7 @@ const selectComponentsV2: SelectComponentsConfig< ); }, Option: ( - props: OptionProps, + props: OptionProps ) => { const { data: sessionClass } = props; return ( @@ -201,7 +202,7 @@ const selectComponentsV2: SelectComponentsConfig< ); }, SingleValue: ( - props: SingleValueProps, + props: SingleValueProps ) => { const { data: sessionClass } = props; return ( @@ -211,7 +212,7 @@ const selectComponentsV2: SelectComponentsConfig< ); }, GroupHeading: ( - props: GroupHeadingProps, + props: GroupHeadingProps ) => { return ( @@ -223,7 +224,7 @@ const selectComponentsV2: SelectComponentsConfig< ); }, MenuList: ( - props: MenuListProps, + props: MenuListProps ) => { return ( cx("pe-2"), groupHeading: () => cx("px-2", styles.groupHeading), @@ -263,7 +264,7 @@ const selectClassNamesV2: ClassNamesConfig< "p-2", styles.option, isFocused && styles.optionIsFocused, - !isFocused && isSelected && styles.optionIsSelected, + !isFocused && isSelected && styles.optionIsSelected ), placeholder: () => cx("px-2"), singleValue: () => cx("d-grid", "gap-1", "px-2", styles.singleValue), @@ -276,22 +277,25 @@ interface OptionOrSingleValueContentProps { const OptionOrSingleValueContent = ({ sessionClass, }: OptionOrSingleValueContentProps) => { + const canBeUsed = + sessionClass.matching && + (sessionClass.usage_available == null || sessionClass.usage_available > 0); const labelClassName = cx( "text-wrap", "text-break", styles.label, - sessionClass.matching && styles.labelMatches, + canBeUsed && styles.labelMatches ); const detailValueClassName = cx(styles.detail, styles.detailValue); const detailLabelClassName = cx(styles.detail, styles.detailLabel); + const icon = canBeUsed ? faCheckCircle : faExclamationTriangle; return ( <> - {" "} - {sessionClass.name} + {sessionClass.name}{" "} + + {usageAvailableString(sessionClass.usage_available, true)} + {" "} {sessionClass.cpu}{" "} CPUs{" "} diff --git a/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx b/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx index 9923580ebf..a930785a30 100644 --- a/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx +++ b/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx @@ -28,6 +28,7 @@ import useLocationHash from "~/utils/customHooks/useLocationHash.hook"; import { ButtonWithMenuV2 } from "../../../components/buttons/Button"; import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; import useProjectPermissions from "../../ProjectPageV2/utils/useProjectPermissions.hook"; +import type { ResourceClassWithIdFiltered } from "../api/computeResources.api"; import { Build, SessionLauncher, @@ -41,14 +42,39 @@ import BuildLauncherButtons, { RebuildLauncherDropdownItem, } from "./BuildLauncherButtons"; -interface SessionLauncherDefaultAction extends Pick< - SessionLauncherButtonsProps, - "hasSession" | "launcher" | "namespace" | "slug" -> { +export function UsageQuotaReachedLaunchButton() { + return ( + <> + + Please launch using a different resource class. The quota for this + resource pool has been fully used. + + + + + + ); +} + +interface SessionLauncherDefaultAction + extends Pick< + SessionLauncherButtonsProps, + "hasSession" | "launcher" | "namespace" | "slug" + > { displayBuildActions: boolean; displayLaunchSession: boolean; imageCheckData: ImageCheckResponse | undefined; imageCheckLoading: boolean; + resourceClass?: ResourceClassWithIdFiltered; + resourcePoolQuotasLoading?: boolean; } function SessionLauncherDefaultAction({ @@ -59,6 +85,7 @@ function SessionLauncherDefaultAction({ imageCheckLoading, launcher, namespace, + resourceClass, slug, }: SessionLauncherDefaultAction) { const { environment } = launcher; @@ -81,7 +108,7 @@ function SessionLauncherDefaultAction({ launcherId: launcher.id, namespace, slug, - }, + } ); if (imageCheckLoading) @@ -91,6 +118,15 @@ function SessionLauncherDefaultAction({ ); + if (resourceClass) { + if ( + resourceClass.usage_available != null && + resourceClass.usage_available <= 0 + ) { + return ; + } + } + const launchAction = displayLaunchSession && ( ); diff --git a/client/src/features/sessionsV2/components/SessionModals/SelectResourceClass.tsx b/client/src/features/sessionsV2/components/SessionModals/SelectResourceClass.tsx index a9c0e887c4..a289a527f1 100644 --- a/client/src/features/sessionsV2/components/SessionModals/SelectResourceClass.tsx +++ b/client/src/features/sessionsV2/components/SessionModals/SelectResourceClass.tsx @@ -136,6 +136,14 @@ export function SelectResourceClassModal({ /> ); + const userLauncherClass = useMemo( + () => + resourcePools + ?.flatMap((pool) => pool.classes) + .find((c) => c.id == launcherClass?.id), + [launcherClass, resourcePools] + ); + const resourceDetails = !isLoadingLauncherClass && launcherClass ? ( ) : (

Resource class not available

diff --git a/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx b/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx index be90d24826..0c8527f0e8 100644 --- a/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx +++ b/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx @@ -33,7 +33,9 @@ import { import { Loader } from "../../../../components/Loader"; import { TimeCaption } from "../../../../components/TimeCaption"; +import { useGetResourcePoolsQuery } from "../../api/computeResources.api"; import type { SessionLauncher } from "../../api/sessionLaunchersV2.api"; +import { UsageAvailable } from "../../session.utils"; import { SESSION_STATES, SESSION_STYLES, @@ -249,7 +251,7 @@ export function SessionStatusV2Description({ session, showInfoDetails = true, }: ActiveSessionDescV2Props) { - const { started, status } = session; + const { resource_class_id, started, status } = session; return (
{started && ( - + )} {showInfoDetails && ( @@ -347,10 +353,12 @@ export function SessionStatusV2Title({ return text ?

{text}

: null; } interface SessionStatusV2TextProps { + resourceClassId: number; startTimestamp: string; status: SessionStatus; } function SessionStatusV2Text({ + resourceClassId, startTimestamp, status, }: SessionStatusV2TextProps) { @@ -361,52 +369,93 @@ function SessionStatusV2Text({ const hibernationTimestamp = state === "hibernated" ? (will_hibernate_at ?? "") : null; - return state === "running" ? ( -
- - Launched {startTimeText} -
- ) : state === "starting" ? ( -
- - Launching since {startTimeText} -
- ) : state === "hibernated" && will_delete_at ? ( -
- - - Session will be deleted in{" "} - + + + Error {"("}created {startTimeText} + {")"} + +
+ ); + + const sessionStatus = + state === "running" ? ( +
+ + Launched {startTimeText} +
+ ) : state === "starting" ? ( +
+ + Launching since {startTimeText} +
+ ) : state === "hibernated" && will_delete_at ? ( +
+ + + Session will be deleted{" "} + + +
+ ) : state === "hibernated" && hibernationTimestamp ? ( +
+ + + Paused + + +
+ ) : ( +
+ + + Paused + + +
+ ); + return ( +
+ {sessionStatus} +
+ - -
- ) : state === "hibernated" && hibernationTimestamp ? ( -
- - - Paused - - -
- ) : state === "hibernated" ? ( -
- - - Paused - - -
- ) : state === "failed" ? ( -
- - - Error {"("}created {startTimeText} - {")"} - +
- ) : null; + ); +} + +function SessionStatusV2TextQuotaInformation({ + resourceClassId, +}: { + resourceClassId: SessionStatusV2TextProps["resourceClassId"]; +}) { + const pollingInterval = 60 * 1000; // 1 minute + const { data: resourcePools } = useGetResourcePoolsQuery( + {}, + { pollingInterval } + ); + const resourceClass = resourcePools + ?.flatMap((pool) => pool.classes) + .find((cls) => cls.id === resourceClassId); + if (!resourceClass || resourceClass.usage_available == null) return null; + + return ( + + + + ); } diff --git a/client/src/features/sessionsV2/components/SessionsList.tsx b/client/src/features/sessionsV2/components/SessionsList.tsx index c093c9019b..87a81c9976 100644 --- a/client/src/features/sessionsV2/components/SessionsList.tsx +++ b/client/src/features/sessionsV2/components/SessionsList.tsx @@ -16,6 +16,9 @@ * limitations under the License. */ +import { UsageAvailable } from "../session.utils"; +import type { SessionLauncherResourceUsageLimit } from "../sessionsV2.types"; + interface SessionLauncherResources { poolName?: string; name?: string; @@ -32,10 +35,12 @@ interface SessionResources { interface SessionRowResourceRequestsProps { resourceRequests: SessionResources["requests"] | SessionLauncherResources; + usageLimit: SessionLauncherResourceUsageLimit; } export function SessionRowResourceRequests({ resourceRequests, + usageLimit, }: SessionRowResourceRequestsProps) { if (!resourceRequests) { return null; @@ -61,24 +66,37 @@ export function SessionRowResourceRequests({ ) : null; return ( -
- {resourceClassName && ( - - {resourceClassName} - {" | "} - - )} - {numericEntries.map(([key, value], index) => ( - - - - {value} {(key === "memory" || key === "storage") && "GB "} + <> +
+ {resourceClassName && ( + + {resourceClassName} + {" | "} + + )} + {numericEntries.map(([key, value], index) => ( + + + + {value} {(key === "memory" || key === "storage") && "GB "} + + {key} - {key} + {numericEntries.length - 1 === index ? " " : " | "} - {numericEntries.length - 1 === index ? " " : " | "} - - ))} -
+ ))} +
+ {usageLimit.resourceClass?.usage_available != null && ( +
+ + + + + +
+ )} + ); } diff --git a/client/src/features/sessionsV2/session.utils.ts b/client/src/features/sessionsV2/session.utils.tsx similarity index 93% rename from client/src/features/sessionsV2/session.utils.ts rename to client/src/features/sessionsV2/session.utils.tsx index 5925293aff..c1ef812593 100644 --- a/client/src/features/sessionsV2/session.utils.ts +++ b/client/src/features/sessionsV2/session.utils.tsx @@ -16,6 +16,9 @@ * limitations under the License */ +import cx from "classnames"; +import { Alarm, Stopwatch } from "react-bootstrap-icons"; + import { FaviconStatus } from "../display/display.types"; import type { ResourcePoolWithId } from "./api/computeResources.api"; import type { @@ -419,3 +422,38 @@ export function isImageCompatibleWith( ); return imagePlatforms.some((p) => p === platform); } + +export function usageAvailableString( + usageAvailableHours: number | undefined, + short = false +): string | null { + if (usageAvailableHours == null) return null; + if (short) return `${usageAvailableHours}h available`; + return `${usageAvailableHours}h of compute time`; +} + +export function UsageAvailable({ + usageAvailableHours, +}: { + usageAvailableHours: number; +}) { + if (usageAvailableHours <= 0) + return ( + <> + + + Usage quota for this resource pool has been reached + + + ); + + return ( + <> + + + {usageAvailableString(usageAvailableHours, false)}{" "} + until quota is used + + + ); +} diff --git a/client/src/features/sessionsV2/sessionsV2.types.ts b/client/src/features/sessionsV2/sessionsV2.types.ts index 4c3cb6b793..41726d1bf3 100644 --- a/client/src/features/sessionsV2/sessionsV2.types.ts +++ b/client/src/features/sessionsV2/sessionsV2.types.ts @@ -18,7 +18,10 @@ import type { ReactNode } from "react"; -import type { ResourceClassWithId } from "./api/computeResources.api"; +import type { + ResourceClassWithId, + ResourceClassWithIdFiltered, +} from "./api/computeResources.api"; import type { BuildParametersPost, DefaultUrl, @@ -126,3 +129,8 @@ export interface SessionEnvironmentVariable { name: string; value: string; } + +export interface SessionLauncherResourceUsageLimit { + resourceClass: ResourceClassWithIdFiltered | undefined; + quotaEnforced: boolean; // TODO: Replace this placeholder with the actual value when available from the API +} diff --git a/tests/cypress/e2e/projectV2Session.spec.ts b/tests/cypress/e2e/projectV2Session.spec.ts index 193fa31f94..c57c4f4cfd 100644 --- a/tests/cypress/e2e/projectV2Session.spec.ts +++ b/tests/cypress/e2e/projectV2Session.spec.ts @@ -301,7 +301,7 @@ describe("launch sessions with data connectors", () => { .contains("Continue") .click(); cy.wait("@testCloudStorage"); - cy.contains("Saving credentials...").should("be.visible"); + cy.contains("Saving credentials").should("be.visible"); cy.wait("@patchDataConnectorSecrets"); cy.wait("@getDataConnectorSecretsAfterSaving"); @@ -1123,3 +1123,167 @@ describe("view autostart link", () => { cy.url().should("match", /\/p\/.*\/sessions\/show\/.*/); }); }); + +describe("launch sessions with resource quotas", () => { + beforeEach(() => { + fixtures + .config() + .versions() + .userTest() + .dataServicesUser({ + response: { + id: "user1-uuid", + username: "user-1", + email: "user1@email.com", + }, + }) + .projects() + .readGroupV2Namespace({ groupSlug: "user1-uuid" }) + .landingUserProjects() + .readProjectV2() + .getStorageSchema({ fixture: "cloudStorage/storage-schema-s3.json" }) + .getResourceClass() + .listProjectV2Members() + .sessionLaunchers({ + fixture: "projectV2/session-launchers.json", + }) + .sessionImage() + .environments() + .listProjectDataConnectors({ + fixture: "dataConnector/empty-list.json", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo1.git", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", + }) + .sessionSecretSlots({ + fixture: "projectV2SessionSecrets/empty_list.json", + }) + .sessionSecrets({ + fixture: "projectV2SessionSecrets/empty_list.json", + }); + cy.visit("/p/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + }); + + it("launch session without resource limits", () => { + fixtures.resourcePoolsTest().sessionServersEmptyV2(); + cy.visit("/p/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.wait("@sessionServersEmptyV2"); + cy.wait("@sessionLaunchers"); + cy.wait("@listProjectDataConnectors"); + + // ensure the session launcher is there + cy.getDataCy("session-launcher-item") + .first() + .within(() => { + cy.getDataCy("session-name").should("contain.text", "Session-custom"); + cy.getDataCy("start-session-button").should("contain.text", "Launch"); + }); + + // start session + cy.fixture("sessions/sessionV2.json").then((session) => { + // eslint-disable-next-line max-nested-callbacks + cy.intercept("POST", "/api/data/sessions", (req) => { + req.reply({ body: session, delay: 2000 }); + }).as("createSession"); + }); + fixtures.getSessionsV2({ fixture: "sessions/sessionsV2.json" }); + cy.getDataCy("session-launcher-item") + .first() + .within(() => { + cy.getDataCy("start-session-button").click(); + }); + cy.wait("@getResourceClass"); + cy.url().should("match", /\/p\/.*\/sessions\/.*\/start$/); + cy.wait("@createSession"); + cy.url().should("match", /\/p\/.*\/sessions\/show\/.*/); + }); + + it("launch session with resource quota consumed", () => { + fixtures + .resourcePoolsTest({ + fixture: "dataServices/resource-pools-consumed.json", + }) + .sessionServersEmptyV2(); + cy.visit("/p/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.wait("@sessionServersEmptyV2"); + cy.wait("@sessionLaunchers"); + cy.wait("@listProjectDataConnectors"); + + // ensure the session launcher is there + cy.getDataCy("session-launcher-item") + .first() + .within(() => { + cy.getDataCy("session-name").should("contain.text", "Session-custom"); + cy.getDataCy("start-session-button").should( + "contain.text", + "Quota Reached" + ); + }) + .click(); + cy.getDataCy("session-view-resource-class-availability").should( + "contain.text", + "Usage quota for this resource pool has been reached" + ); + cy.getDataCy("get-back-session-view").click(); + + // start session + cy.fixture("sessions/sessionV2.json").then((session) => { + // eslint-disable-next-line max-nested-callbacks + cy.intercept("POST", "/api/data/sessions", (req) => { + req.reply({ body: session, delay: 2000 }); + }).as("createSession"); + }); + fixtures.getSessionsV2({ fixture: "sessions/sessionsV2.json" }); + cy.getDataCy("session-launcher-item") + .first() + .within(() => { + cy.getDataCy("start-session-button").should("be.disabled"); + cy.getDataCy("button-with-menu-dropdown").click(); + cy.getDataCy("start-custom-session-button").click(); + }); + cy.wait("@getResourcePools"); + cy.get(".modal-body").within(() => { + cy.get("p").should( + "contain.text", + "Please select one of your available resource classes to continue." + ); + // cy.get("#react-select-5-placeholder").click(); + cy.contains("Select...").should("be.visible").click(); + cy.contains("0h available").should("be.visible"); + cy.contains("200h available").should("be.visible").click(); + }); + cy.get("button").contains("Continue").click(); + cy.contains("200h of compute time until quota is used").should( + "be.visible" + ); + }); + + it("resume session with resource quota consumed", () => { + fixtures + .resourcePoolsTest({ + fixture: "dataServices/resource-pools-consumed.json", + }) + .getSessionsV2({ fixture: "sessions/sessionsV2Paused.json" }); + cy.visit("/p/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.wait("@getSessionsV2"); + cy.wait("@sessionLaunchers"); + cy.wait("@listProjectDataConnectors"); + cy.wait("@getResourcePools"); + + cy.getDataCy("session-launcher-item") + .first() + .within(() => { + cy.getDataCy("start-session-button").should("be.disabled"); + // The resume button should not be disabled at the moment + // cy.getDataCy("resume-session-button").should("be.disabled"); + cy.getDataCy("resume-session-button").should("be.enabled"); + }); + }); +}); diff --git a/tests/cypress/fixtures/dataServices/resource-pools-consumed.json b/tests/cypress/fixtures/dataServices/resource-pools-consumed.json new file mode 100644 index 0000000000..b436f88464 --- /dev/null +++ b/tests/cypress/fixtures/dataServices/resource-pools-consumed.json @@ -0,0 +1,112 @@ +[ + { + "id": 1, + "name": "Public pool", + "default": true, + "public": true, + "quota": { + "cpu": 100, + "memory": 1000, + "gpu": 0, + "storage": 1000000 + }, + "user_used": 100, + "classes": [ + { + "id": 1, + "name": "public class 1", + "cpu": 1, + "memory": 1, + "gpu": 0, + "max_storage": 20, + "default_storage": 5, + "default": true, + "matching": false, + "usage_available": 0, + "usage_available_percentage": 0 + }, + { + "id": 2, + "name": "public class 2", + "cpu": 2, + "memory": 2, + "gpu": 0, + "max_storage": 40, + "default_storage": 5, + "default": false, + "matching": true, + "usage_available": 0, + "usage_available_percentage": 0 + } + ] + }, + { + "id": 2, + "name": "Special pool", + "default": false, + "public": false, + "idle_threshold": 3000, + "hibernation_threshold": 6000, + "quota": { + "cpu": 200, + "memory": 8000, + "gpu": 40, + "storage": 10000000 + }, + "user_used": 0, + "classes": [ + { + "id": 3, + "name": "special class 1", + "cpu": 2, + "memory": 4, + "gpu": 0, + "max_storage": 40, + "default_storage": 10, + "default": false, + "matching": true, + "usage_available": 200, + "usage_available_percentage": 100 + }, + { + "id": 4, + "name": "special class 2", + "cpu": 4, + "memory": 8, + "gpu": 1, + "max_storage": 40, + "default_storage": 10, + "default": false, + "matching": true, + "usage_available": 100, + "usage_available_percentage": 100 + }, + { + "id": 5, + "name": "special class 3", + "cpu": 8, + "memory": 16, + "gpu": 1, + "max_storage": 40, + "default_storage": 10, + "default": false, + "matching": true, + "usage_available": 50, + "usage_available_percentage": 100 + }, + { + "id": 6, + "name": "special class 4", + "cpu": 8, + "memory": 32, + "gpu": 1, + "max_storage": 40, + "default_storage": 10, + "default": false, + "matching": true, + "usage_available": 25, + "usage_available_percentage": 100 + } + ] + } +] diff --git a/tests/cypress/fixtures/dataServices/resource-pools.json b/tests/cypress/fixtures/dataServices/resource-pools.json index de294e0b80..aca44b3fc4 100644 --- a/tests/cypress/fixtures/dataServices/resource-pools.json +++ b/tests/cypress/fixtures/dataServices/resource-pools.json @@ -10,6 +10,7 @@ "gpu": 0, "storage": 1000000 }, + "user_used": 10, "classes": [ { "id": 1, @@ -20,7 +21,9 @@ "max_storage": 20, "default_storage": 5, "default": true, - "matching": false + "matching": false, + "usage_available": 90, + "usage_available_percentage": 90 }, { "id": 2, @@ -31,7 +34,9 @@ "max_storage": 40, "default_storage": 5, "default": false, - "matching": true + "matching": true, + "usage_available": 45, + "usage_available_percentage": 90 } ] }, diff --git a/tests/cypress/fixtures/sessions/sessionsV2Paused.json b/tests/cypress/fixtures/sessions/sessionsV2Paused.json new file mode 100644 index 0000000000..f9099f8fde --- /dev/null +++ b/tests/cypress/fixtures/sessions/sessionsV2Paused.json @@ -0,0 +1,22 @@ +[ + { + "image": "renku/renkulab-py:3.10-0.15.0", + "name": "renku-2-86688c93091df68dffdc594bfd022ce3", + "resources": { + "cpu": 0.1, + "memory": "1G", + "storage": "1G" + }, + "started": "2024-04-19T09:44:38+00:00", + "status": { + "state": "hibernated", + "will_delete_at": "2024-04-20T09:44:38+00:00", + "ready_containers": 0, + "total_containers": 0 + }, + "url": "https://dev.renku.ch/sessions/renku-2-86688c93091df68dffdc594bfd022ce3", + "project_id": "THEPROJECTULID26CHARACTERS", + "launcher_id": "01HYJE99XEKWNKPYN8WRB6QA8Z", + "resource_class_id": 1 + } +] diff --git a/tests/cypress/support/renkulab-fixtures/sessions.ts b/tests/cypress/support/renkulab-fixtures/sessions.ts index 086740f778..22b00b7130 100644 --- a/tests/cypress/support/renkulab-fixtures/sessions.ts +++ b/tests/cypress/support/renkulab-fixtures/sessions.ts @@ -38,8 +38,25 @@ export function Sessions(Parent: T) { getSessionsV2(args?: SimpleFixture) { const { fixture = "sessions/sessions.json", name = "getSessionsV2" } = args ?? {}; - const response = { fixture }; - cy.intercept("GET", "/api/data/sessions*", response).as(name); + cy.fixture(fixture).then((sessions) => { + const currentSessions = sessions.map( + (session: Record) => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + session.started = fiveMinutesAgo.toISOString(); + const status = session.status as Record; + if (status.state == "hibernated") { + status.will_delete_at = new Date( + fiveMinutesAgo.getTime() + 24 * 60 * 60 * 1000 + ).toISOString(); + } + return session; + } + ); + // eslint-disable-next-line max-nested-callbacks + cy.intercept("GET", "/api/data/sessions*", (req) => { + req.reply({ body: currentSessions }); + }).as(name); + }); return this; } From 03e86870618d8d192071c88994aa6a27dcabc057 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Fri, 17 Apr 2026 17:18:26 +0200 Subject: [PATCH 2/5] feat: maintain resource usage limits on AdminPage (#4137) * build: expose resourceUsage api --- client/package.json | 6 +- client/scripts/update_api_spec.js | 9 + client/src/features/admin/AdminPage.tsx | 62 ++- .../UpdateResourceClassCostButton.tsx | 241 +++++++++ .../UpdateResourcePoolUsageLimitsButton.tsx | 218 ++++++++ .../api/resourceUsage.api-config.ts | 34 ++ .../resourceUsage/api/resourceUsage.api.ts | 81 +++ .../api/resourceUsage.empty-api.ts | 26 + .../api/resourceUsage.generated-api.ts | 161 ++++++ .../api/resourceUsage.openapi.json | 500 ++++++++++++++++++ client/src/store/store.ts | 3 + tests/cypress/e2e/adminPage.spec.ts | 44 ++ .../dataServices/resource-pool-limits.json | 1 + .../support/renkulab-fixtures/dataServices.ts | 48 ++ 14 files changed, 1429 insertions(+), 5 deletions(-) create mode 100644 client/src/features/resourceUsage/UpdateResourceClassCostButton.tsx create mode 100644 client/src/features/resourceUsage/UpdateResourcePoolUsageLimitsButton.tsx create mode 100644 client/src/features/resourceUsage/api/resourceUsage.api-config.ts create mode 100644 client/src/features/resourceUsage/api/resourceUsage.api.ts create mode 100644 client/src/features/resourceUsage/api/resourceUsage.empty-api.ts create mode 100644 client/src/features/resourceUsage/api/resourceUsage.generated-api.ts create mode 100644 client/src/features/resourceUsage/api/resourceUsage.openapi.json create mode 100644 tests/cypress/fixtures/dataServices/resource-pool-limits.json diff --git a/client/package.json b/client/package.json index 8f46f0ac52..8c336d8d90 100644 --- a/client/package.json +++ b/client/package.json @@ -21,7 +21,7 @@ "storybook-wait-server": "wait-on http://127.0.0.1:6006", "storybook-test": "test-storybook", "storybook-compile-and-test": "concurrently -k -s first -n 'BUILD,TEST' -c 'magenta,blue' 'npm run storybook-build && npm run storybook-start-server' 'npm run storybook-wait-server && npm run storybook-test'", - "generate-api": "npm run generate-api:computeResources && npm run generate-api:connectedServices && npm run generate-api:data-connectors && npm run generate-api:doiResolver && npm run generate-api:namespaceV2 && npm run generate-api:notifications && npm run generate-api:platform && npm run generate-api:projectCloudStorage && npm run generate-api:projectV2 && npm run generate-api:repositories && npm run generate-api:searchV2 && npm run generate-api:sessionLaunchersV2 && npm run generate-api:sessionsV2 && npm run generate-api:users", + "generate-api": "npm run generate-api:computeResources && npm run generate-api:connectedServices && npm run generate-api:data-connectors && npm run generate-api:doiResolver && npm run generate-api:namespaceV2 && npm run generate-api:notifications && npm run generate-api:platform && npm run generate-api:projectCloudStorage && npm run generate-api:projectV2 && npm run generate-api:repositories && npm run generate-api:resourceUsage && npm run generate-api:searchV2 && npm run generate-api:sessionLaunchersV2 && npm run generate-api:sessionsV2 && npm run generate-api:users", "generate-api:computeResources": "rtk-query-codegen-openapi src/features/sessionsV2/api/computeResources.api-config.ts", "generate-api:connectedServices": "rtk-query-codegen-openapi src/features/connectedServices/api/connectedServices.api-config.ts", "generate-api:data-connectors": "rtk-query-codegen-openapi src/features/dataConnectorsV2/api/data-connectors.api-config.ts", @@ -32,11 +32,12 @@ "generate-api:projectCloudStorage": "rtk-query-codegen-openapi src/features/cloudStorage/api/projectCloudStorage.api-config.ts", "generate-api:projectV2": "rtk-query-codegen-openapi src/features/projectsV2/api/projectV2.api-config.ts", "generate-api:repositories": "rtk-query-codegen-openapi src/features/repositories/api/repositories.api-config.ts", + "generate-api:resourceUsage": "rtk-query-codegen-openapi src/features/resourceUsage/api/resourceUsage.api-config.ts", "generate-api:searchV2": "rtk-query-codegen-openapi src/features/searchV2/api/searchV2.api-config.ts", "generate-api:sessionLaunchersV2": "rtk-query-codegen-openapi src/features/sessionsV2/api/sessionLaunchersV2.api-config.ts", "generate-api:sessionsV2": "rtk-query-codegen-openapi src/features/sessionsV2/api/sessionsV2.api-config.ts", "generate-api:users": "rtk-query-codegen-openapi src/features/usersV2/api/users.api-config.ts", - "update-api": "node scripts/update_api_spec.js computeResources connectedServices dataConnectors namespaceV2 notifications platform projectCloudStorage projectV2 repositories searchV2 sessionLaunchersV2 sessionsV2 users", + "update-api": "node scripts/update_api_spec.js computeResources connectedServices dataConnectors namespaceV2 notifications platform projectCloudStorage projectV2 repositories resourceUsage searchV2 sessionLaunchersV2 sessionsV2 users", "update-api:computeResources": "node scripts/update_api_spec.js computeResources", "update-api:connectedServices": "node scripts/update_api_spec.js connectedServices", "update-api:dataConnectors": "node scripts/update_api_spec.js dataConnectors", @@ -46,6 +47,7 @@ "update-api:projectCloudStorage": "node scripts/update_api_spec.js projectCloudStorage", "update-api:projectV2": "node scripts/update_api_spec.js projectV2", "update-api:repositories": "node scripts/update_api_spec.js repositories", + "update-api:resourceUsage": "node scripts/update_api_spec.js resourceUsage", "update-api:searchV2": "node scripts/update_api_spec.js searchV2", "update-api:sessionLaunchersV2": "node scripts/update_api_spec.js sessionLaunchersV2", "update-api:sessionsV2": "node scripts/update_api_spec.js sessionsV2", diff --git a/client/scripts/update_api_spec.js b/client/scripts/update_api_spec.js index aef8c17867..44c1e744ec 100644 --- a/client/scripts/update_api_spec.js +++ b/client/scripts/update_api_spec.js @@ -46,6 +46,8 @@ async function main() { updateProjectV2Api(); } else if (arg.trim() === "repositories") { updateRepositoriesApi(); + } else if (arg.trim() === "resourceUsage") { + updateResourceUsageApi(); } else if (arg.trim() === "searchV2") { updateSearchV2Api(); } else if (arg.trim() === "sessionLaunchersV2") { @@ -122,6 +124,13 @@ async function updateRepositoriesApi() { }); } +async function updateResourceUsageApi() { + updateApiFiles({ + specFile: "components/renku_data_services/resource_usage/api.spec.yaml", + destFile: "src/features/resourceUsage/api/resourceUsage.openapi.json", + }); +} + async function updateSearchV2Api() { updateApiFiles({ specFile: "components/renku_data_services/search/api.spec.yaml", diff --git a/client/src/features/admin/AdminPage.tsx b/client/src/features/admin/AdminPage.tsx index 7ca217b384..6fe6d5e67d 100644 --- a/client/src/features/admin/AdminPage.tsx +++ b/client/src/features/admin/AdminPage.tsx @@ -36,6 +36,9 @@ import ChevronFlippedIcon from "~/components/icons/ChevronFlippedIcon"; import { Loader } from "~/components/Loader"; import { isFetchBaseQueryError } from "~/utils/helpers/ApiErrors"; import { toFullHumanDuration } from "~/utils/helpers/DurationUtils"; +import { useGetResourcePoolsByResourcePoolIdLimitsQuery } from "../resourceUsage/api/resourceUsage.api"; +import UpdateResourceClassCostButton from "../resourceUsage/UpdateResourceClassCostButton"; +import UpdateResourcePoolUsageLimitsButton from "../resourceUsage/UpdateResourcePoolUsageLimitsButton"; import { useDeleteResourcePoolsByResourcePoolIdMutation, useDeleteResourcePoolsByResourcePoolIdUsersAndUserIdMutation, @@ -44,6 +47,7 @@ import { type PoolUserWithId, type ResourceClassWithId, type ResourcePoolWithId, + type ResourcePoolWithIdFiltered, } from "../sessionsV2/api/computeResources.api"; import { useGetUsersQuery } from "../usersV2/api/users.api"; import { useGetNotebooksVersionQuery } from "../versions/versions.api"; @@ -155,7 +159,10 @@ function ResourcePoolsList() { } interface ResourcePoolItemProps { - resourcePool: ResourcePoolWithId; + // TODO: Cluster is not declared as being in the response + // check if it should be added to the API spec + resourcePool: ResourcePoolWithIdFiltered & + Pick; } function ResourcePoolItem({ resourcePool }: ResourcePoolItemProps) { @@ -173,6 +180,9 @@ function ResourcePoolItem({ resourcePool }: ResourcePoolItemProps) { const toggle = useCallback(() => { setIsOpen((isOpen) => !isOpen); }, []); + const { data: usageLimits } = useGetResourcePoolsByResourcePoolIdLimitsQuery({ + resourcePoolId: resourcePool.id, + }); return ( @@ -228,7 +238,7 @@ function ResourcePoolItem({ resourcePool }: ResourcePoolItemProps) { )} >
- Quota: + Resource Quota:
{quota.cpu} CPUs
{quota.memory} GB RAM
@@ -241,7 +251,37 @@ function ResourcePoolItem({ resourcePool }: ResourcePoolItemProps) {

No quota

)}
- +
+
+
+ Usage Quota: +
+ {usageLimits != null && ( +
+ {usageLimits.user_limit} credits / user +
+ )} + {usageLimits != null && ( +
+ {usageLimits.total_limit} credits total +
+ )} +
+ +
+
+
{clusterId != null ? (

@@ -440,6 +480,22 @@ function ResourceClassItem({

node affinities: {node_affinities?.length ?? 0}
+
+ +
0 ? poolLimits.user_limit : poolLimits.total_limit; + const hoursPerUser = (poolUserLimit / cost).toFixed(2); + return `${hoursPerUser} hours / user`; +} + +export default function UpdateResourceClassCostButton({ + resourceClass, + resourcePool, +}: UpdateResourceClassCostButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const toggle = useCallback(() => { + setIsOpen((open) => !open); + }, []); + + const { data: classCost } = + useGetResourcePoolsByResourcePoolIdClassesAndClassIdCostQuery({ + resourcePoolId: resourcePool.id, + classId: resourceClass.id, + }); + + return ( + <> + + + + ); +} + +interface UpdateResourceClassCostModalProps { + isOpen: boolean; + classCost: number | undefined; + resourceClass: ResourceClassWithId; + resourcePool: ResourcePoolWithId; + toggle: () => void; +} + +function UpdateResourceClassCostModal({ + isOpen, + resourceClass, + resourcePool, + toggle, + classCost, +}: UpdateResourceClassCostModalProps) { + const { id, name: resourcePoolName } = resourcePool; + const { id: resourceClassId, name: resourceClassName } = resourceClass; + + const { data: poolLimits } = useGetResourcePoolsByResourcePoolIdLimitsQuery({ + resourcePoolId: id, + }); + + const [updateResourceClassCost, result] = + usePutResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation(); + const [deleteResourceClassCost, deleteResult] = + useDeleteResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation(); + + const { control, handleSubmit, reset } = useForm( + { + defaultValues: { + cost: classCost, + }, + } + ); + const formCost = useWatch({ control, name: "cost" }); + + useEffect(() => { + if (isOpen) { + reset({ + cost: classCost, + }); + } + }, [isOpen, reset, classCost]); + const onSubmit = useCallback( + (data: UpdateResourceClassCostForm) => { + if (data.cost <= 0) { + deleteResourceClassCost({ + resourcePoolId: id, + classId: resourceClassId, + }); + return; + } + updateResourceClassCost({ + resourcePoolId: id, + classId: resourceClassId, + resourceClassCostPut: { + cost: data.cost, + }, + }); + }, + [id, resourceClassId, updateResourceClassCost, deleteResourceClassCost] + ); + + useEffect(() => { + if (result.isSuccess || deleteResult.isSuccess) { + toggle(); + } + }, [deleteResult.isSuccess, result.isSuccess, toggle]); + + return ( + + + Update cost for {resourcePoolName} - {resourceClassName} + + +
+ {result.error && } + +
+ + ( + <> + + + {resourceClassUsageLimitText(formCost, poolLimits)} + + + )} + /> +
+ +
+ + + + +
+ ); +} + +interface UpdateResourceClassCostForm { + cost: number; +} diff --git a/client/src/features/resourceUsage/UpdateResourcePoolUsageLimitsButton.tsx b/client/src/features/resourceUsage/UpdateResourcePoolUsageLimitsButton.tsx new file mode 100644 index 0000000000..c6c7a0355d --- /dev/null +++ b/client/src/features/resourceUsage/UpdateResourcePoolUsageLimitsButton.tsx @@ -0,0 +1,218 @@ +/*! + * Copyright 2026 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { skipToken } from "@reduxjs/toolkit/query"; +import cx from "classnames"; +import { useCallback, useEffect, useState } from "react"; +import { CheckLg, XLg } from "react-bootstrap-icons"; +import { Controller, useForm } from "react-hook-form"; +import { + Button, + Form, + Input, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; + +import RtkOrDataServicesError from "~/components/errors/RtkOrDataServicesError"; +import { Loader } from "~/components/Loader"; +import { + useGetResourcePoolsByResourcePoolIdLimitsQuery, + usePutResourcePoolsByResourcePoolIdLimitsMutation, +} from "../resourceUsage/api/resourceUsage.api"; +import { type ResourcePoolWithId } from "../sessionsV2/api/computeResources.api"; + +interface UpdateResourcePoolUsageLimitsProps { + resourcePool: ResourcePoolWithId; +} + +export default function UpdateResourcePoolUsageLimitsButton({ + resourcePool, +}: UpdateResourcePoolUsageLimitsProps) { + const [isOpen, setIsOpen] = useState(false); + const toggle = useCallback(() => { + setIsOpen((open) => !open); + }, []); + + return ( + <> + + + + ); +} + +interface UpdateResourcePoolUsageLimitsModalProps { + isOpen: boolean; + resourcePool: ResourcePoolWithId; + toggle: () => void; +} + +function UpdateResourcePoolUsageLimitsModal({ + isOpen, + resourcePool, + toggle, +}: UpdateResourcePoolUsageLimitsModalProps) { + const { id, name } = resourcePool; + + const { data: usageLimits } = useGetResourcePoolsByResourcePoolIdLimitsQuery( + isOpen + ? { + resourcePoolId: id, + } + : skipToken + ); + + const [updateResourcePoolLimits, result] = + usePutResourcePoolsByResourcePoolIdLimitsMutation(); + + const { control, handleSubmit, reset } = + useForm({ + defaultValues: { + user: usageLimits?.user_limit, + total: usageLimits?.total_limit, + }, + }); + + useEffect(() => { + if (isOpen) { + reset({ + user: usageLimits?.user_limit, + total: usageLimits?.total_limit, + }); + } + }, [isOpen, reset, usageLimits]); + const onSubmit = useCallback( + (data: UpdateResourcePoolUsageLimitsForm) => { + updateResourcePoolLimits({ + resourcePoolId: id, + resourcePoolLimitPut: { + user_limit: data.user, + total_limit: data.total, + }, + }); + }, + [id, updateResourcePoolLimits] + ); + + useEffect(() => { + if (result.isSuccess) { + toggle(); + } + }, [result.isSuccess, toggle]); + + return ( + + + Update usage limits for {name} + + +
+ {result.error && } + +
+ + ( + + )} + /> +
+ +
+ + ( + + )} + /> +
+ +
+ + + + +
+ ); +} + +interface UpdateResourcePoolUsageLimitsForm { + user: number; + total: number; +} diff --git a/client/src/features/resourceUsage/api/resourceUsage.api-config.ts b/client/src/features/resourceUsage/api/resourceUsage.api-config.ts new file mode 100644 index 0000000000..60e7d0667a --- /dev/null +++ b/client/src/features/resourceUsage/api/resourceUsage.api-config.ts @@ -0,0 +1,34 @@ +/*! + * Copyright 2026 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run `npm run generate-api:resourceUsage` to generate the API + +import path from "path"; +import type { ConfigFile } from "@rtk-query/codegen-openapi"; + +const config: ConfigFile = { + // Configure to inject endpoints into the dataConnectorsApi + apiFile: "./resourceUsage.empty-api.ts", + apiImport: "resourceUsageEmptyApi", + outputFile: "./resourceUsage.generated-api.ts", + exportName: "resourceUsageGeneratedApi", + hooks: true, + schemaFile: path.join(__dirname, "resourceUsage.openapi.json"), +}; + +export default config; diff --git a/client/src/features/resourceUsage/api/resourceUsage.api.ts b/client/src/features/resourceUsage/api/resourceUsage.api.ts new file mode 100644 index 0000000000..45981f6aff --- /dev/null +++ b/client/src/features/resourceUsage/api/resourceUsage.api.ts @@ -0,0 +1,81 @@ +import { computeResourcesApi } from "../../sessionsV2/api/computeResources.api"; +import { resourceUsageGeneratedApi } from "./resourceUsage.generated-api"; + +const withTagHandling = resourceUsageGeneratedApi.enhanceEndpoints({ + addTagTypes: ["ResourcePoolLimits", "ResourceClassCost"], + endpoints: { + getResourcePoolsByResourcePoolIdLimits: { + providesTags: (result, _error, arg) => + result + ? [ + { id: arg.resourcePoolId, type: "ResourcePoolLimits" }, + "ResourcePoolLimits", + ] + : ["ResourcePoolLimits"], + }, + putResourcePoolsByResourcePoolIdLimits: { + invalidatesTags: (result, _error, arg) => + result + ? [{ id: arg.resourcePoolId, type: "ResourcePoolLimits" }] + : ["ResourcePoolLimits"], + onQueryStarted: async (_arg, { dispatch, queryFulfilled }) => { + await queryFulfilled; + dispatch(computeResourcesApi.util.invalidateTags(["ResourcePool"])); + }, + }, + deleteResourcePoolsByResourcePoolIdClassesAndClassIdCost: { + invalidatesTags: (result, _error, arg) => + result + ? [ + { + id: `${arg.resourcePoolId}-${arg.classId}`, + type: "ResourceClassCost", + }, + ] + : ["ResourceClassCost"], + onQueryStarted: async (_arg, { dispatch, queryFulfilled }) => { + await queryFulfilled; + dispatch(computeResourcesApi.util.invalidateTags(["ResourcePool"])); + }, + }, + getResourcePoolsByResourcePoolIdClassesAndClassIdCost: { + providesTags: (result, _error, arg) => + result + ? [ + { + id: `${arg.resourcePoolId}-${arg.classId}`, + type: "ResourceClassCost", + }, + "ResourceClassCost", + ] + : ["ResourceClassCost"], + }, + putResourcePoolsByResourcePoolIdClassesAndClassIdCost: { + invalidatesTags: (result, _error, arg) => + result + ? [ + { + id: `${arg.resourcePoolId}-${arg.classId}`, + type: "ResourceClassCost", + }, + ] + : ["ResourceClassCost"], + onQueryStarted: async (_arg, { dispatch, queryFulfilled }) => { + await queryFulfilled; + dispatch(computeResourcesApi.util.invalidateTags(["ResourcePool"])); + }, + }, + }, +}); + +export const { + useGetResourcePoolsByResourcePoolIdUsageQuery, + useGetResourcePoolsByResourcePoolIdLimitsQuery, + usePutResourcePoolsByResourcePoolIdLimitsMutation, + useDeleteResourcePoolsByResourcePoolIdLimitsMutation, + useGetResourcePoolsByResourcePoolIdClassesAndClassIdCostQuery, + usePutResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation, + useDeleteResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation, +} = withTagHandling; + +export type * from "./resourceUsage.generated-api"; diff --git a/client/src/features/resourceUsage/api/resourceUsage.empty-api.ts b/client/src/features/resourceUsage/api/resourceUsage.empty-api.ts new file mode 100644 index 0000000000..4301100dac --- /dev/null +++ b/client/src/features/resourceUsage/api/resourceUsage.empty-api.ts @@ -0,0 +1,26 @@ +/*! + * Copyright 2026 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; + +// initialize an empty api service that we'll inject endpoints into later as needed +export const resourceUsageEmptyApi = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: "/api/data" }), + endpoints: () => ({}), + reducerPath: "resourceUsageApi", +}); diff --git a/client/src/features/resourceUsage/api/resourceUsage.generated-api.ts b/client/src/features/resourceUsage/api/resourceUsage.generated-api.ts new file mode 100644 index 0000000000..3be1e66ddb --- /dev/null +++ b/client/src/features/resourceUsage/api/resourceUsage.generated-api.ts @@ -0,0 +1,161 @@ +import { resourceUsageEmptyApi as api } from "./resourceUsage.empty-api"; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getResourcePoolsByResourcePoolIdUsage: build.query< + GetResourcePoolsByResourcePoolIdUsageApiResponse, + GetResourcePoolsByResourcePoolIdUsageApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/usage`, + params: { start_date: queryArg.startDate, end_date: queryArg.endDate }, + }), + }), + getResourcePoolsByResourcePoolIdLimits: build.query< + GetResourcePoolsByResourcePoolIdLimitsApiResponse, + GetResourcePoolsByResourcePoolIdLimitsApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/limits`, + }), + }), + putResourcePoolsByResourcePoolIdLimits: build.mutation< + PutResourcePoolsByResourcePoolIdLimitsApiResponse, + PutResourcePoolsByResourcePoolIdLimitsApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/limits`, + method: "PUT", + body: queryArg.resourcePoolLimitPut, + }), + }), + deleteResourcePoolsByResourcePoolIdLimits: build.mutation< + DeleteResourcePoolsByResourcePoolIdLimitsApiResponse, + DeleteResourcePoolsByResourcePoolIdLimitsApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/limits`, + method: "DELETE", + }), + }), + getResourcePoolsByResourcePoolIdClassesAndClassIdCost: build.query< + GetResourcePoolsByResourcePoolIdClassesAndClassIdCostApiResponse, + GetResourcePoolsByResourcePoolIdClassesAndClassIdCostApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/classes/${queryArg.classId}/cost`, + }), + }), + putResourcePoolsByResourcePoolIdClassesAndClassIdCost: build.mutation< + PutResourcePoolsByResourcePoolIdClassesAndClassIdCostApiResponse, + PutResourcePoolsByResourcePoolIdClassesAndClassIdCostApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/classes/${queryArg.classId}/cost`, + method: "PUT", + body: queryArg.resourceClassCostPut, + }), + }), + deleteResourcePoolsByResourcePoolIdClassesAndClassIdCost: build.mutation< + DeleteResourcePoolsByResourcePoolIdClassesAndClassIdCostApiResponse, + DeleteResourcePoolsByResourcePoolIdClassesAndClassIdCostApiArg + >({ + query: (queryArg) => ({ + url: `/resource_pools/${queryArg.resourcePoolId}/classes/${queryArg.classId}/cost`, + method: "DELETE", + }), + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as resourceUsageGeneratedApi }; +export type GetResourcePoolsByResourcePoolIdUsageApiResponse = + /** status 200 Return the pool limits and current usage. */ ResourcePoolUsage; +export type GetResourcePoolsByResourcePoolIdUsageApiArg = { + resourcePoolId: number; + startDate?: string; + endDate?: string; +}; +export type GetResourcePoolsByResourcePoolIdLimitsApiResponse = + /** status 200 Return the resource pool limits. */ ResourcePoolLimits; +export type GetResourcePoolsByResourcePoolIdLimitsApiArg = { + resourcePoolId: number; +}; +export type PutResourcePoolsByResourcePoolIdLimitsApiResponse = + /** status 200 The input limits have been updated. */ ResourcePoolLimitPut; +export type PutResourcePoolsByResourcePoolIdLimitsApiArg = { + resourcePoolId: number; + resourcePoolLimitPut: ResourcePoolLimitPut; +}; +export type DeleteResourcePoolsByResourcePoolIdLimitsApiResponse = + /** status 204 No content on success. */ void; +export type DeleteResourcePoolsByResourcePoolIdLimitsApiArg = { + resourcePoolId: number; +}; +export type GetResourcePoolsByResourcePoolIdClassesAndClassIdCostApiResponse = + /** status 200 Return the resource class costs. */ ResourceClassCost; +export type GetResourcePoolsByResourcePoolIdClassesAndClassIdCostApiArg = { + resourcePoolId: number; + classId: number; +}; +export type PutResourcePoolsByResourcePoolIdClassesAndClassIdCostApiResponse = + /** status 200 The input cost that has been set. */ ResourceClassCostPut; +export type PutResourcePoolsByResourcePoolIdClassesAndClassIdCostApiArg = { + resourcePoolId: number; + classId: number; + resourceClassCostPut: ResourceClassCostPut; +}; +export type DeleteResourcePoolsByResourcePoolIdClassesAndClassIdCostApiResponse = + /** status 204 No content on success. */ void; +export type DeleteResourcePoolsByResourcePoolIdClassesAndClassIdCostApiArg = { + resourcePoolId: number; + classId: number; +}; +export type ResourceUsageSummary = { + runtime: number; + cost: number; +}; +export type ResourcePoolLimits = { + pool_id: number; + total_limit: number; + user_limit: number; +}; +export type ResourcePoolUsage = { + total_usage: ResourceUsageSummary; + user_usage: ResourceUsageSummary; + pool_limits: ResourcePoolLimits; +}; +export type ErrorResponse = { + error: { + code: number; + detail?: string; + message: string; + /** Sentry trace ID for linking to corresponding log entries */ + trace_id?: string; + }; +}; +export type ResourcePoolLimitPut = { + total_limit: number; + user_limit: number; +}; +export type ResourceClassCost = { + resource_pool_id: number; + resource_class_id: number; + cost: number; +}; +export type ResourceClassCostPut = { + /** The cost of a resource class is an integer that specifies + the effective cost of a session using this class running + for one hour. + */ + cost: number; +}; +export const { + useGetResourcePoolsByResourcePoolIdUsageQuery, + useGetResourcePoolsByResourcePoolIdLimitsQuery, + usePutResourcePoolsByResourcePoolIdLimitsMutation, + useDeleteResourcePoolsByResourcePoolIdLimitsMutation, + useGetResourcePoolsByResourcePoolIdClassesAndClassIdCostQuery, + usePutResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation, + useDeleteResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation, +} = injectedRtkApi; diff --git a/client/src/features/resourceUsage/api/resourceUsage.openapi.json b/client/src/features/resourceUsage/api/resourceUsage.openapi.json new file mode 100644 index 0000000000..073909b95d --- /dev/null +++ b/client/src/features/resourceUsage/api/resourceUsage.openapi.json @@ -0,0 +1,500 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Renku Data Services API", + "description": "This service is the main backend for Renku. It provides information about users, projects,\ncloud storage, access to compute resources and many other things.\n", + "version": "v1" + }, + "servers": [ + { + "url": "/api/data" + } + ], + "paths": { + "/resource_pools/{resource_pool_id}/usage": { + "get": { + "summary": "Get usage and limits of the supplied pool. If a `start_date` is not given, the usage of the current week is returned.", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Return the pool limits and current usage.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourcePoolUsage" + } + } + } + }, + "404": { + "description": "The resource pool doesn't exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource-usage"] + } + }, + "/resource_pools/{resource_pool_id}/limits": { + "get": { + "summary": "Get resource pool limits", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Return the resource pool limits.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourcePoolLimits" + } + } + } + }, + "404": { + "description": "The pool does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource-usage"] + }, + "put": { + "summary": "Update resource pool limits.", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourcePoolLimitPut" + } + } + } + }, + "responses": { + "200": { + "description": "The input limits have been updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourcePoolLimitPut" + } + } + } + }, + "404": { + "description": "The pool does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource-usage"] + }, + "delete": { + "summary": "Delete resource pool limits.", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No content on success." + }, + "500": { + "description": "There was an internal error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource-usage"] + } + }, + "/resource_pools/{resource_pool_id}/classes/{class_id}/cost": { + "get": { + "summary": "Get resource class costs.", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "class_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Return the resource class costs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceClassCost" + } + } + } + }, + "404": { + "description": "The pool/class does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource-usage"] + }, + "put": { + "summary": "Update resource class costs.", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "class_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceClassCostPut" + } + } + } + }, + "responses": { + "200": { + "description": "The input cost that has been set.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceClassCostPut" + } + } + } + }, + "404": { + "description": "The class does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource-usage"] + }, + "delete": { + "summary": "Delete resource class costs.", + "parameters": [ + { + "in": "path", + "name": "resource_pool_id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "class_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No content on success." + }, + "404": { + "description": "The pool/class does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["resource-usage"] + } + } + }, + "components": { + "schemas": { + "ResourcePoolUsage": { + "type": "object", + "additionalProperties": false, + "properties": { + "total_usage": { + "$ref": "#/components/schemas/ResourceUsageSummary" + }, + "user_usage": { + "$ref": "#/components/schemas/ResourceUsageSummary" + }, + "pool_limits": { + "$ref": "#/components/schemas/ResourcePoolLimits" + } + }, + "required": ["total_usage", "user_usage", "pool_limits"] + }, + "ResourceUsageSummary": { + "type": "object", + "additionalProperties": false, + "properties": { + "runtime": { + "type": "number", + "format": "double" + }, + "cost": { + "type": "integer" + } + }, + "required": ["runtime", "cost"] + }, + "ResourcePoolLimitPut": { + "type": "object", + "additionalProperties": false, + "properties": { + "total_limit": { + "type": "integer" + }, + "user_limit": { + "type": "integer" + } + }, + "required": ["total_limit", "user_limit"] + }, + "ResourceClassCostPut": { + "type": "object", + "additionalProperties": false, + "properties": { + "cost": { + "type": "integer", + "description": "The cost of a resource class is an integer that specifies\nthe effective cost of a session using this class running\nfor one hour.\n" + } + }, + "required": ["cost"] + }, + "ResourceClassCost": { + "type": "object", + "additionalProperties": false, + "properties": { + "resource_pool_id": { + "type": "integer" + }, + "resource_class_id": { + "type": "integer" + }, + "cost": { + "type": "integer" + } + }, + "required": ["resource_pool_id", "resource_class_id", "cost"] + }, + "ResourcePoolLimits": { + "type": "object", + "additionalProperties": false, + "properties": { + "pool_id": { + "type": "integer" + }, + "total_limit": { + "type": "integer" + }, + "user_limit": { + "type": "integer" + } + }, + "required": ["pool_id", "total_limit", "user_limit"] + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "example": 1404 + }, + "detail": { + "type": "string", + "example": "A more detailed optional message showing what the problem was" + }, + "message": { + "type": "string", + "example": "Something went wrong - please try again later" + }, + "trace_id": { + "type": "string", + "example": "ac93950e9e114a55c67fb8e5ef519bbe", + "description": "Sentry trace ID for linking to corresponding log entries" + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + }, + "Ulid": { + "description": "ULID identifier", + "type": "string", + "minLength": 26, + "maxLength": 26, + "pattern": "^[0-7][0-9A-HJKMNP-TV-Z]{25}$" + } + }, + "responses": { + "Error": { + "description": "The schema for all 4xx and 5xx responses", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "securitySchemes": { + "bearer": { + "scheme": "bearer", + "type": "http" + }, + "oidc": { + "type": "openIdConnect", + "openIdConnectUrl": "/auth/realms/Renku/.well-known/openid-configuration" + } + } + }, + "security": [ + { + "bearer": [] + }, + { + "oidc": ["openid"] + } + ] +} diff --git a/client/src/store/store.ts b/client/src/store/store.ts index d7e4bddf33..a510499f96 100644 --- a/client/src/store/store.ts +++ b/client/src/store/store.ts @@ -32,6 +32,7 @@ import { platformEmptyApi as platformApi } from "~/features/platform/api/platfor import { statuspageEmptyApi as statuspageApi } from "~/features/platform/statuspage-api/statuspage-empty.api"; import { projectV2Api } from "~/features/projectsV2/api/projectV2.enhanced-api"; import { repositoriesApi } from "~/features/repositories/api/repositories.api"; +import { resourceUsageEmptyApi as resourceUsageApi } from "~/features/resourceUsage/api/resourceUsage.empty-api"; import { searchV2EmptyApi as searchV2Api } from "~/features/searchV2/api/searchV2-empty.api"; import { searchV2Slice } from "~/features/searchV2/searchV2.slice"; import { computeResourcesEmptyApi as computeResourcesApi } from "~/features/sessionsV2/api/computeResources.empty-api"; @@ -66,6 +67,7 @@ export const store = configureStore({ [projectCloudStorageApi.reducerPath]: projectCloudStorageApi.reducer, [projectV2Api.reducerPath]: projectV2Api.reducer, [repositoriesApi.reducerPath]: repositoriesApi.reducer, + [resourceUsageApi.reducerPath]: resourceUsageApi.reducer, [searchV2Api.reducerPath]: searchV2Api.reducer, [sessionLaunchersV2Api.reducerPath]: sessionLaunchersV2Api.reducer, [sessionsV2Api.reducerPath]: sessionsV2Api.reducer, @@ -90,6 +92,7 @@ export const store = configureStore({ .concat(projectCloudStorageApi.middleware) .concat(projectV2Api.middleware) .concat(repositoriesApi.middleware) + .concat(resourceUsageApi.middleware) .concat(searchV2Api.middleware) .concat(sessionLaunchersV2Api.middleware) .concat(sessionsV2Api.middleware) diff --git a/tests/cypress/e2e/adminPage.spec.ts b/tests/cypress/e2e/adminPage.spec.ts index 3043815bec..7976c82dc0 100644 --- a/tests/cypress/e2e/adminPage.spec.ts +++ b/tests/cypress/e2e/adminPage.spec.ts @@ -268,4 +268,48 @@ describe("admin page", () => { .click(); cy.wait("@postResourcePool"); }); + + it("should allow editing usage quotas", () => { + fixtures + .userAdmin() + .resourcePoolsTest() + .resourcePoolLimits({ resourcePoolId: 2 }) + .putResourcePoolLimits({ + resourcePoolId: 2, + totalLimit: 100, + userLimit: 10, + }) + .adminResourcePoolUsers() + .adminKeycloakUser(); + cy.visit("/"); + cy.wait("@getUser"); + + cy.visit("/admin"); + + // check public resource pool + cy.get(".card") + .contains("button", "Special pool") + .should("be.visible") + .click(); + cy.get(".card") + .contains(".card", "Special pool") + .within(() => { + cy.contains("10 credits / user").should("be.visible"); + cy.getDataCy("update-resource-pool-usage-limits-button") + .should("be.visible") + .click(); + }); + cy.get("button[type='submit']").should("be.visible").click(); + cy.wait("@putResourcePoolLimits"); + cy.get(".card") + .contains(".card", "Special pool") + .within(() => { + cy.getDataCy("update-resource-class-cost-button") + .first() + .should("be.visible") + .click(); + }); + cy.get("#UpdateResourceClassCost-2").type("2"); + cy.contains("5.00 hours / user").should("be.visible"); + }); }); diff --git a/tests/cypress/fixtures/dataServices/resource-pool-limits.json b/tests/cypress/fixtures/dataServices/resource-pool-limits.json new file mode 100644 index 0000000000..b0cb955cee --- /dev/null +++ b/tests/cypress/fixtures/dataServices/resource-pool-limits.json @@ -0,0 +1 @@ +{ "pool_id": 2, "total_limit": 100, "user_limit": 10 } diff --git a/tests/cypress/support/renkulab-fixtures/dataServices.ts b/tests/cypress/support/renkulab-fixtures/dataServices.ts index e573bf399b..d9246b1d7f 100644 --- a/tests/cypress/support/renkulab-fixtures/dataServices.ts +++ b/tests/cypress/support/renkulab-fixtures/dataServices.ts @@ -39,6 +39,17 @@ interface PostResourcePoolArgs extends SimpleFixture { }; } +interface ResourcePoolIdFixture extends SimpleFixture { + resourcePoolId: number; +} + +interface PutResourcePoolLimitsFixture + extends Omit { + resourcePoolId: number; + totalLimit: number; + userLimit: number; +} + interface UrlRedirectFixture extends NameOnlyFixture { sourceUrl: string; targetUrl: string | null; @@ -101,6 +112,43 @@ export function DataServices(Parent: T) { return this; } + resourcePoolLimits(args?: ResourcePoolIdFixture) { + const { + fixture = "dataServices/resource-pool-limits.json", + name = "getResourcePoolLimits", + resourcePoolId = 1, + } = args ?? {}; + cy.fixture(fixture).then((limits) => { + limits = { ...limits, resource_pool_id: resourcePoolId }; + cy.intercept( + "GET", + `/api/data/resource_pools/${resourcePoolId}/limits`, + { body: limits } + ).as(name); + }); + return this; + } + + putResourcePoolLimits(args?: PutResourcePoolLimitsFixture) { + const { + name = "putResourcePoolLimits", + resourcePoolId = 1, + totalLimit, + userLimit, + } = args ?? {}; + const limits = { total_limit: totalLimit, user_limit: userLimit }; + cy.intercept( + "PUT", + `/api/data/resource_pools/${resourcePoolId}/limits`, + (req) => { + expect(req.body.total_limit).to.equal(totalLimit); + expect(req.body.user_limit).to.equal(userLimit); + req.reply({ body: limits }); + } + ).as(name); + return this; + } + dataServicesUser(args: DataServicesUserFixture) { const { response: response_, name = "getDataServicesUser" } = args; const response = { From 3b2727d6a0b3800a51b4f803314c49a6171bfe0e Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Thu, 7 May 2026 15:47:45 +0200 Subject: [PATCH 3/5] build: update computeResources api --- .../api/computeResources.generated-api.ts | 60 ++-- .../api/computeResources.openapi.json | 328 +++++++----------- 2 files changed, 139 insertions(+), 249 deletions(-) diff --git a/client/src/features/sessionsV2/api/computeResources.generated-api.ts b/client/src/features/sessionsV2/api/computeResources.generated-api.ts index af04584cb2..4798d43821 100644 --- a/client/src/features/sessionsV2/api/computeResources.generated-api.ts +++ b/client/src/features/sessionsV2/api/computeResources.generated-api.ts @@ -532,12 +532,11 @@ export type PutUsersByUserIdResourcePoolsApiArg = { export type GetVersionApiResponse = /** status 200 The error */ Version; export type GetVersionApiArg = void; export type Name = string; +export type DefaultFlag = boolean; export type Cpu = number; export type Memory = number; export type Gpu = number; export type Storage = number; -export type IntegerId = number; -export type DefaultFlag = boolean; export type K8SLabel = string; export type K8SLabelList = K8SLabel[]; export type NodeAffinity = { @@ -545,17 +544,18 @@ export type NodeAffinity = { required_during_scheduling?: boolean; }; export type NodeAffinityList = NodeAffinity[]; +export type IntegerId = number; export type ResourceClassWithId = { name: Name; + default: DefaultFlag; cpu: Cpu; memory: Memory; gpu: Gpu; max_storage: Storage; default_storage: Storage; - id: IntegerId; - default: DefaultFlag; tolerations?: K8SLabelList; node_affinities?: NodeAffinityList; + id: IntegerId; }; export type ErrorResponse = { error: { @@ -629,22 +629,12 @@ export type QuotaWithId = { gpu: Gpu; id: Name; }; -export type UsageAvailable = number; -export type UsageLimitTotal = number; -export type ResourceClassWithIdFiltered = { - name: Name; - cpu: Cpu; - memory: Memory; - gpu: Gpu; - max_storage: Storage; - default_storage: Storage; - id: IntegerId; - default: DefaultFlag; +export type UsageHoursRemaining = number; +export type UsageHoursTotal = number; +export type ResourceClassWithIdFiltered = ResourceClassWithId & { matching?: boolean; - tolerations?: K8SLabelList; - node_affinities?: NodeAffinityList; - usage_available?: UsageAvailable; - usage_limit_total?: UsageLimitTotal; + usage_hours_remaining?: UsageHoursRemaining; + usage_hours_total?: UsageHoursTotal; }; export type PublicFlag = boolean; export type RemoteConfigurationFirecrestProviderId = string; @@ -673,7 +663,7 @@ export type IdleThreshold = number; export type HibernationThreshold = number; export type HibernationWarningPeriod = number; export type RuntimePlatform = "linux/amd64" | "linux/arm64"; -export type ResourceUsage = number; +export type CreditsUsed = number; export type ResourcePoolWithIdFiltered = { quota?: QuotaWithId; classes: ResourceClassWithIdFiltered[]; @@ -687,7 +677,7 @@ export type ResourcePoolWithIdFiltered = { hibernation_warning_period?: HibernationWarningPeriod; cluster_id?: Ulid; platform: RuntimePlatform; - resource_usage?: ResourceUsage; + credits_used?: CreditsUsed; }; export type ResourcePoolsWithIdFiltered = ResourcePoolWithIdFiltered[]; export type CpuFilter = number; @@ -717,12 +707,12 @@ export type QuotaWithOptionalId = { }; export type ResourceClass = { name: Name; + default: DefaultFlag; cpu: Cpu; memory: Memory; gpu: Gpu; max_storage: Storage; default_storage: Storage; - default: DefaultFlag; tolerations?: K8SLabelList; node_affinities?: NodeAffinityList; }; @@ -759,21 +749,21 @@ export type QuotaPatch = { memory?: Memory; gpu?: Gpu; }; -export type DefaultFlagPatch = boolean; -export type ResourceClassPatchWithId = { +export type ResourceClassProperties = { name?: Name; + default?: DefaultFlag; cpu?: Cpu; memory?: Memory; gpu?: Gpu; max_storage?: Storage; default_storage?: Storage; - id: IntegerId; - default?: DefaultFlagPatch; tolerations?: K8SLabelList; node_affinities?: NodeAffinityList; }; +export type ResourceClassPatchWithId = ResourceClassProperties & { + id?: IntegerId; +}; export type ResourceClassesPatchWithId = ResourceClassPatchWithId[]; -export type PublicFlagPatch = boolean; export type RemoteConfigurationPatchReset = object; export type RemoteConfigurationFirecrestPatch = { /** Kind of remote resource pool */ @@ -797,8 +787,8 @@ export type ResourcePoolPatch = { quota?: QuotaPatch; classes?: ResourceClassesPatchWithId; name?: Name; - public?: PublicFlagPatch; - default?: DefaultFlagPatch; + public?: PublicFlag; + default?: DefaultFlag; remote?: RemoteConfigurationPatch; idle_threshold?: IdleThreshold; hibernation_threshold?: HibernationThreshold; @@ -807,17 +797,7 @@ export type ResourcePoolPatch = { platform?: RuntimePlatform; }; export type ResourceClassesWithIdResponse = ResourceClassWithId[]; -export type ResourceClassPatch = { - name?: Name; - cpu?: Cpu; - memory?: Memory; - gpu?: Gpu; - max_storage?: Storage; - default_storage?: Storage; - default?: DefaultFlagPatch; - tolerations?: K8SLabelList; - node_affinities?: NodeAffinityList; -}; +export type ResourceClassPatch = ResourceClassProperties; export type NodeAffinityListResponse = NodeAffinity[]; export type UserId = string; export type PoolUserWithId = { diff --git a/client/src/features/sessionsV2/api/computeResources.openapi.json b/client/src/features/sessionsV2/api/computeResources.openapi.json index 1da61b803f..49c011fdd0 100644 --- a/client/src/features/sessionsV2/api/computeResources.openapi.json +++ b/client/src/features/sessionsV2/api/computeResources.openapi.json @@ -295,19 +295,35 @@ "additionalProperties": false, "properties": { "cpu": { - "$ref": "#/components/schemas/CpuFilter", + "allOf": [ + { + "$ref": "#/components/schemas/CpuFilter" + } + ], "default": 0 }, "gpu": { - "$ref": "#/components/schemas/Gpu", + "allOf": [ + { + "$ref": "#/components/schemas/Gpu" + } + ], "default": 0 }, "memory": { - "$ref": "#/components/schemas/MemoryFilter", + "allOf": [ + { + "$ref": "#/components/schemas/MemoryFilter" + } + ], "default": 0 }, "max_storage": { - "$ref": "#/components/schemas/StorageFilter", + "allOf": [ + { + "$ref": "#/components/schemas/StorageFilter" + } + ], "default": 0 } } @@ -1677,13 +1693,16 @@ "$ref": "#/components/schemas/ClusterWithId" } }, - "ResourceClass": { + "ResourceClassProperties": { "type": "object", "additionalProperties": false, "properties": { "name": { "$ref": "#/components/schemas/Name" }, + "default": { + "$ref": "#/components/schemas/DefaultFlag" + }, "cpu": { "$ref": "#/components/schemas/Cpu" }, @@ -1699,42 +1718,24 @@ "default_storage": { "$ref": "#/components/schemas/Storage" }, - "default": { - "$ref": "#/components/schemas/DefaultFlag" - }, "tolerations": { "$ref": "#/components/schemas/K8sLabelList" }, "node_affinities": { "$ref": "#/components/schemas/NodeAffinityList" } - }, - "required": [ - "cpu", - "memory", - "gpu", - "max_storage", - "name", - "default", - "default_storage" - ], - "example": { - "name": "resource class", - "cpu": 1.5, - "memory": 2, - "gpu": 0, - "max_storage": 100, - "default": false, - "default_storage": 10 } }, - "ResourceClassPatch": { + "ResourceClass": { "type": "object", "additionalProperties": false, "properties": { "name": { "$ref": "#/components/schemas/Name" }, + "default": { + "$ref": "#/components/schemas/DefaultFlag" + }, "cpu": { "$ref": "#/components/schemas/Cpu" }, @@ -1750,9 +1751,6 @@ "default_storage": { "$ref": "#/components/schemas/Storage" }, - "default": { - "$ref": "#/components/schemas/DefaultFlagPatch" - }, "tolerations": { "$ref": "#/components/schemas/K8sLabelList" }, @@ -1760,46 +1758,50 @@ "$ref": "#/components/schemas/NodeAffinityList" } }, + "required": [ + "name", + "default", + "cpu", + "memory", + "gpu", + "max_storage", + "default_storage" + ], + "example": { + "name": "resource class", + "default": false, + "cpu": 1.5, + "memory": 2, + "gpu": 0, + "max_storage": 100, + "default_storage": 10 + } + }, + "ResourceClassPatch": { + "allOf": [ + { + "$ref": "#/components/schemas/ResourceClassProperties" + } + ], "example": { "name": "resource class", "cpu": 1.5 } }, "ResourceClassPatchWithId": { - "type": "object", - "additionalProperties": false, - "properties": { - "name": { - "$ref": "#/components/schemas/Name" - }, - "cpu": { - "$ref": "#/components/schemas/Cpu" - }, - "memory": { - "$ref": "#/components/schemas/Memory" - }, - "gpu": { - "$ref": "#/components/schemas/Gpu" - }, - "max_storage": { - "$ref": "#/components/schemas/Storage" - }, - "default_storage": { - "$ref": "#/components/schemas/Storage" - }, - "id": { - "$ref": "#/components/schemas/IntegerId" - }, - "default": { - "$ref": "#/components/schemas/DefaultFlagPatch" - }, - "tolerations": { - "$ref": "#/components/schemas/K8sLabelList" + "allOf": [ + { + "$ref": "#/components/schemas/ResourceClassProperties" }, - "node_affinities": { - "$ref": "#/components/schemas/NodeAffinityList" + { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/IntegerId" + } + } } - }, + ], "required": ["id"], "example": { "id": 1, @@ -1813,6 +1815,9 @@ "name": { "$ref": "#/components/schemas/Name" }, + "default": { + "$ref": "#/components/schemas/DefaultFlag" + }, "cpu": { "$ref": "#/components/schemas/Cpu" }, @@ -1828,103 +1833,66 @@ "default_storage": { "$ref": "#/components/schemas/Storage" }, - "id": { - "$ref": "#/components/schemas/IntegerId" - }, - "default": { - "$ref": "#/components/schemas/DefaultFlag" - }, "tolerations": { "$ref": "#/components/schemas/K8sLabelList" }, "node_affinities": { "$ref": "#/components/schemas/NodeAffinityList" + }, + "id": { + "$ref": "#/components/schemas/IntegerId" } }, "required": [ + "name", + "default", "cpu", "memory", "gpu", "max_storage", - "name", - "id", - "default", - "default_storage" + "default_storage", + "id" ], "example": { "name": "resource class", + "default": true, "cpu": 1.5, "memory": 2, "gpu": 0, "max_storage": 100, "default_storage": 10, - "id": 1, - "default": true + "id": 1 } }, "ResourceClassWithIdFiltered": { - "type": "object", - "additionalProperties": false, - "properties": { - "name": { - "$ref": "#/components/schemas/Name" - }, - "cpu": { - "$ref": "#/components/schemas/Cpu" - }, - "memory": { - "$ref": "#/components/schemas/Memory" - }, - "gpu": { - "$ref": "#/components/schemas/Gpu" - }, - "max_storage": { - "$ref": "#/components/schemas/Storage" - }, - "default_storage": { - "$ref": "#/components/schemas/Storage" - }, - "id": { - "$ref": "#/components/schemas/IntegerId" - }, - "default": { - "$ref": "#/components/schemas/DefaultFlag" - }, - "matching": { - "type": "boolean" - }, - "tolerations": { - "$ref": "#/components/schemas/K8sLabelList" - }, - "node_affinities": { - "$ref": "#/components/schemas/NodeAffinityList" - }, - "usage_available": { - "$ref": "#/components/schemas/UsageAvailable" + "allOf": [ + { + "$ref": "#/components/schemas/ResourceClassWithId" }, - "usage_limit_total": { - "$ref": "#/components/schemas/UsageLimitTotal" + { + "type": "object", + "properties": { + "matching": { + "type": "boolean" + }, + "usage_hours_remaining": { + "$ref": "#/components/schemas/UsageHoursRemaining" + }, + "usage_hours_total": { + "$ref": "#/components/schemas/UsageHoursTotal" + } + } } - }, - "required": [ - "cpu", - "memory", - "gpu", - "max_storage", - "name", - "id", - "default", - "default_storage" ], "example": { "name": "resource class", + "default": true, "cpu": 1.5, "memory": 2, "gpu": 0, "max_storage": 100, "default_storage": 10, "id": 1, - "default": true, "matching": true } }, @@ -1943,23 +1911,23 @@ "example": [ { "name": "resource class 1", + "default": true, "cpu": 1.5, "memory": 2, "gpu": 0, "max_storage": 100, - "id": 1, - "default": true, - "default_storage": 10 + "default_storage": 10, + "id": 1 }, { "name": "resource class 2", + "default": false, "cpu": 4.5, "memory": 10, "gpu": 2, - "default_storage": 10, "max_storage": 10000, - "id": 2, - "default": false + "default_storage": 10, + "id": 2 } ] }, @@ -1972,23 +1940,23 @@ "example": [ { "name": "resource class 1", + "default": true, "cpu": 1.5, "memory": 2, "gpu": 0, "max_storage": 100, - "id": 1, - "default": true, - "default_storage": 10 + "default_storage": 10, + "id": 1 }, { "name": "resource class 2", + "default": false, "cpu": 4.5, "memory": 10, "gpu": 2, - "default_storage": 10, "max_storage": 10000, - "id": 2, - "default": false + "default_storage": 10, + "id": 2 } ] }, @@ -2097,10 +2065,10 @@ "$ref": "#/components/schemas/Name" }, "public": { - "$ref": "#/components/schemas/PublicFlagPatch" + "$ref": "#/components/schemas/PublicFlag" }, "default": { - "$ref": "#/components/schemas/DefaultFlagPatch" + "$ref": "#/components/schemas/DefaultFlag" }, "remote": { "$ref": "#/components/schemas/RemoteConfigurationPatch" @@ -2335,8 +2303,8 @@ "platform": { "$ref": "#/components/schemas/RuntimePlatform" }, - "resource_usage": { - "$ref": "#/components/schemas/ResourceUsage" + "credits_used": { + "$ref": "#/components/schemas/CreditsUsed" } }, "required": ["classes", "name", "id", "public", "default", "platform"], @@ -2388,33 +2356,6 @@ "$ref": "#/components/schemas/ResourcePoolWithIdFiltered" } }, - "UserPatch": { - "type": "object", - "additionalProperties": false, - "properties": { - "no_default_access": { - "type": "boolean", - "description": "If set to true the user will not be able to use the default resource pool" - } - }, - "example": { - "no_default_access": true - } - }, - "UserPut": { - "type": "object", - "additionalProperties": false, - "properties": { - "no_default_access": { - "type": "boolean", - "description": "If set to true the user will not be able to use the default resource pool" - } - }, - "required": ["no_default_access"], - "example": { - "no_default_access": true - } - }, "PoolUserWithId": { "type": "object", "additionalProperties": false, @@ -2555,26 +2496,25 @@ "minimum": 0, "maximum": 9223372036854776000 }, - "UsageAvailable": { + "UsageHoursRemaining": { "type": "number", "format": "double", - "description": "Amount of a resource available (in hours)", + "description": "Number of resource hours remaining for the user", "example": 3.141, "minimum": 0 }, - "UsageLimitTotal": { + "UsageHoursTotal": { "type": "number", - "format": "float", - "description": "Total number of a resources hours available to the user", + "format": "double", + "description": "Total number of resource hours available to the user", "example": 10, "minimum": 0, "default": null }, - "ResourceUsage": { - "type": "number", - "format": "double", - "description": "Amount of a resource used (in credits)", - "example": 3.141, + "CreditsUsed": { + "type": "integer", + "description": "Amount of the resource credits used so far", + "example": 300, "minimum": 0 }, "Storage": { @@ -2626,23 +2566,11 @@ "example": "a-remote-cluster.yaml" }, "DefaultFlag": { - "type": "boolean", - "description": "A default selection for resource classes or resource pools", - "default": false, - "example": false - }, - "DefaultFlagPatch": { "type": "boolean", "description": "A default selection for resource classes or resource pools", "example": false }, "PublicFlag": { - "type": "boolean", - "description": "A resource pool whose classes can be accessed by anyone", - "default": false, - "example": false - }, - "PublicFlagPatch": { "type": "boolean", "description": "A resource pool whose classes can be accessed by anyone", "example": false @@ -2984,24 +2912,6 @@ } } } - }, - "securitySchemes": { - "bearer": { - "scheme": "bearer", - "type": "http" - }, - "oidc": { - "type": "openIdConnect", - "openIdConnectUrl": "/auth/realms/Renku/.well-known/openid-configuration" - } - } - }, - "security": [ - { - "bearer": [] - }, - { - "oidc": ["openid"] } - ] + } } From a4bac4eeb7119b0945a9ebce88c616d51662d051 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Thu, 7 May 2026 16:02:57 +0200 Subject: [PATCH 4/5] refactor: rename usage_available to usage_hours_remaining --- .../StartSessionProgressBar.tsx | 6 +++-- .../features/sessionsV2/SessionStartPage.tsx | 8 +++---- .../sessionsV2/StartSessionButton.tsx | 4 ++-- .../SessionButton/ActiveSessionButton.tsx | 4 ++-- .../components/SessionClassSelector.tsx | 5 ++-- .../components/SessionLauncherButtons.tsx | 4 ++-- .../SessionStatus/SessionStatus.tsx | 7 ++++-- .../sessionsV2/components/SessionsList.tsx | 6 +++-- .../dataServices/resource-pools-consumed.json | 24 +++++++++---------- 9 files changed, 38 insertions(+), 30 deletions(-) diff --git a/client/src/features/sessionsV2/SessionShowPage/StartSessionProgressBar.tsx b/client/src/features/sessionsV2/SessionShowPage/StartSessionProgressBar.tsx index 00d2589075..fc39f08a87 100644 --- a/client/src/features/sessionsV2/SessionShowPage/StartSessionProgressBar.tsx +++ b/client/src/features/sessionsV2/SessionShowPage/StartSessionProgressBar.tsx @@ -56,8 +56,10 @@ export function StartSessionProgressBarV2({

Launching Session

Starting session services

- {resourceClass?.usage_available != null && ( - + {resourceClass?.usage_hours_remaining != null && ( + )}
diff --git a/client/src/features/sessionsV2/SessionStartPage.tsx b/client/src/features/sessionsV2/SessionStartPage.tsx index ce0c1bb6f5..d7df9faa43 100644 --- a/client/src/features/sessionsV2/SessionStartPage.tsx +++ b/client/src/features/sessionsV2/SessionStartPage.tsx @@ -324,9 +324,9 @@ function SessionStarting({ launcher, project }: StartSessionFromLauncherProps) { title={`Launching session ${launcher.name}`} status={steps} extraDescription={ - resourceClass?.usage_available != null && ( + resourceClass?.usage_hours_remaining != null && ( ) } @@ -669,9 +669,9 @@ function StartSessionFromLauncher({ title={`Launching session ${launcher.name}`} status={steps} extraDescription={ - resourceClass?.usage_available != null && ( + resourceClass?.usage_hours_remaining != null && ( ) } diff --git a/client/src/features/sessionsV2/StartSessionButton.tsx b/client/src/features/sessionsV2/StartSessionButton.tsx index c02726bda0..2d9ecc5cdf 100644 --- a/client/src/features/sessionsV2/StartSessionButton.tsx +++ b/client/src/features/sessionsV2/StartSessionButton.tsx @@ -53,8 +53,8 @@ function SessionStartDefaultActionButton({ if (resourceClass) { if ( - resourceClass.usage_available != null && - resourceClass.usage_available <= 0 + resourceClass.usage_hours_remaining != null && + resourceClass.usage_hours_remaining <= 0 ) { return ; } diff --git a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx index 4839ad4696..0c528b05b6 100644 --- a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx +++ b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx @@ -181,8 +181,8 @@ function ActiveSessionDefaultButton({ if (status === "hibernated") { if ( usageLimit.quotaEnforced && - usageLimit.resourceClass?.usage_available != null && - usageLimit.resourceClass.usage_available <= 0 + usageLimit.resourceClass?.usage_hours_remaining != null && + usageLimit.resourceClass.usage_hours_remaining <= 0 ) { return ( <> diff --git a/client/src/features/sessionsV2/components/SessionClassSelector.tsx b/client/src/features/sessionsV2/components/SessionClassSelector.tsx index 0a8bc98864..8713e61531 100644 --- a/client/src/features/sessionsV2/components/SessionClassSelector.tsx +++ b/client/src/features/sessionsV2/components/SessionClassSelector.tsx @@ -279,7 +279,8 @@ const OptionOrSingleValueContent = ({ }: OptionOrSingleValueContentProps) => { const canBeUsed = sessionClass.matching && - (sessionClass.usage_available == null || sessionClass.usage_available > 0); + (sessionClass.usage_hours_remaining == null || + sessionClass.usage_hours_remaining > 0); const labelClassName = cx( "text-wrap", "text-break", @@ -294,7 +295,7 @@ const OptionOrSingleValueContent = ({ {sessionClass.name}{" "} - {usageAvailableString(sessionClass.usage_available, true)} + {usageAvailableString(sessionClass.usage_hours_remaining, true)} {" "} {sessionClass.cpu}{" "} diff --git a/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx b/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx index a930785a30..558a89441a 100644 --- a/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx +++ b/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx @@ -120,8 +120,8 @@ function SessionLauncherDefaultAction({ if (resourceClass) { if ( - resourceClass.usage_available != null && - resourceClass.usage_available <= 0 + resourceClass.usage_hours_remaining != null && + resourceClass.usage_hours_remaining <= 0 ) { return ; } diff --git a/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx b/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx index 0c8527f0e8..5616a68692 100644 --- a/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx +++ b/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx @@ -451,11 +451,14 @@ function SessionStatusV2TextQuotaInformation({ const resourceClass = resourcePools ?.flatMap((pool) => pool.classes) .find((cls) => cls.id === resourceClassId); - if (!resourceClass || resourceClass.usage_available == null) return null; + if (!resourceClass || resourceClass.usage_hours_remaining == null) + return null; return ( - + ); } diff --git a/client/src/features/sessionsV2/components/SessionsList.tsx b/client/src/features/sessionsV2/components/SessionsList.tsx index 87a81c9976..c0eaf02250 100644 --- a/client/src/features/sessionsV2/components/SessionsList.tsx +++ b/client/src/features/sessionsV2/components/SessionsList.tsx @@ -86,12 +86,14 @@ export function SessionRowResourceRequests({ ))}
- {usageLimit.resourceClass?.usage_available != null && ( + {usageLimit.resourceClass?.usage_hours_remaining != null && (
diff --git a/tests/cypress/fixtures/dataServices/resource-pools-consumed.json b/tests/cypress/fixtures/dataServices/resource-pools-consumed.json index b436f88464..201905954d 100644 --- a/tests/cypress/fixtures/dataServices/resource-pools-consumed.json +++ b/tests/cypress/fixtures/dataServices/resource-pools-consumed.json @@ -22,8 +22,8 @@ "default_storage": 5, "default": true, "matching": false, - "usage_available": 0, - "usage_available_percentage": 0 + "usage_hours_remaining": 0, + "usage_hours_total": 1 }, { "id": 2, @@ -35,8 +35,8 @@ "default_storage": 5, "default": false, "matching": true, - "usage_available": 0, - "usage_available_percentage": 0 + "usage_hours_remaining": 0, + "usage_hours_total": 1 } ] }, @@ -65,8 +65,8 @@ "default_storage": 10, "default": false, "matching": true, - "usage_available": 200, - "usage_available_percentage": 100 + "usage_hours_remaining": 200, + "usage_hours_total": 200 }, { "id": 4, @@ -78,8 +78,8 @@ "default_storage": 10, "default": false, "matching": true, - "usage_available": 100, - "usage_available_percentage": 100 + "usage_hours_remaining": 100, + "usage_hours_total": 100 }, { "id": 5, @@ -91,8 +91,8 @@ "default_storage": 10, "default": false, "matching": true, - "usage_available": 50, - "usage_available_percentage": 100 + "usage_hours_remaining": 50, + "usage_hours_total": 50 }, { "id": 6, @@ -104,8 +104,8 @@ "default_storage": 10, "default": false, "matching": true, - "usage_available": 25, - "usage_available_percentage": 100 + "usage_hours_remaining": 25, + "usage_hours_total": 25 } ] } From 0b0637d829c8a702594c182ea961c1980ac49220 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Tue, 12 May 2026 11:25:41 +0200 Subject: [PATCH 5/5] minor: format with new prettier --- client/src/features/admin/AdminPage.tsx | 4 +- .../dashboardV2/DashboardV2Sessions.tsx | 4 +- .../UpdateResourceClassCostButton.tsx | 10 +-- .../UpdateResourcePoolUsageLimitsButton.tsx | 4 +- .../sessionsV2/SessionList/SessionCard.tsx | 4 +- .../SessionList/SessionLauncherCard.tsx | 20 ++--- .../features/sessionsV2/SessionStartPage.tsx | 84 +++++++++---------- .../sessionsV2/SessionView/SessionView.tsx | 2 +- .../sessionsV2/StartSessionButton.tsx | 16 ++-- .../SessionButton/ActiveSessionButton.tsx | 29 ++++--- .../components/SessionClassSelector.tsx | 24 +++--- .../components/SessionLauncherButtons.tsx | 17 ++-- .../SessionModals/SelectResourceClass.tsx | 2 +- .../SessionStatus/SessionStatus.tsx | 2 +- .../src/features/sessionsV2/session.utils.tsx | 2 +- tests/cypress/e2e/projectV2Session.spec.ts | 8 +- .../support/renkulab-fixtures/dataServices.ts | 10 ++- .../support/renkulab-fixtures/sessions.ts | 4 +- 18 files changed, 124 insertions(+), 122 deletions(-) diff --git a/client/src/features/admin/AdminPage.tsx b/client/src/features/admin/AdminPage.tsx index 6fe6d5e67d..57eeb15f18 100644 --- a/client/src/features/admin/AdminPage.tsx +++ b/client/src/features/admin/AdminPage.tsx @@ -259,7 +259,7 @@ function ResourcePoolItem({ resourcePool }: ResourcePoolItemProps) { "row-cols-1", "row-cols-sm-4", "row-cols-md-5", - "text-end" + "text-end", )} >
@@ -488,7 +488,7 @@ function ResourceClassItem({ "flex-column", "flex-sm-row", "flex-wrap", - "justify-content-end" + "justify-content-end", )} > pool.classes) .find((c) => c.id == currentSessionClassId), - [currentSessionClassId, resourcePools] + [currentSessionClassId, resourcePools], ); return ( diff --git a/client/src/features/resourceUsage/UpdateResourceClassCostButton.tsx b/client/src/features/resourceUsage/UpdateResourceClassCostButton.tsx index dd5a0955fb..0a3dc024e0 100644 --- a/client/src/features/resourceUsage/UpdateResourceClassCostButton.tsx +++ b/client/src/features/resourceUsage/UpdateResourceClassCostButton.tsx @@ -53,7 +53,7 @@ interface UpdateResourceClassCostButtonProps { function resourceClassUsageLimitText( cost: number | undefined, - poolLimits: ResourcePoolLimits | undefined + poolLimits: ResourcePoolLimits | undefined, ) { if (cost == null || cost <= 0 || poolLimits == null) { return "Users can use this resource class without limit"; @@ -92,8 +92,8 @@ export default function UpdateResourceClassCostButton({ {classCost?.cost == null ? "None" : classCost.cost <= 0 - ? "None" - : classCost.cost} + ? "None" + : classCost.cost} { diff --git a/client/src/features/resourceUsage/UpdateResourcePoolUsageLimitsButton.tsx b/client/src/features/resourceUsage/UpdateResourcePoolUsageLimitsButton.tsx index c6c7a0355d..84f50feb5d 100644 --- a/client/src/features/resourceUsage/UpdateResourcePoolUsageLimitsButton.tsx +++ b/client/src/features/resourceUsage/UpdateResourcePoolUsageLimitsButton.tsx @@ -90,7 +90,7 @@ function UpdateResourcePoolUsageLimitsModal({ ? { resourcePoolId: id, } - : skipToken + : skipToken, ); const [updateResourcePoolLimits, result] = @@ -122,7 +122,7 @@ function UpdateResourcePoolUsageLimitsModal({ }, }); }, - [id, updateResourcePoolLimits] + [id, updateResourcePoolLimits], ); useEffect(() => { diff --git a/client/src/features/sessionsV2/SessionList/SessionCard.tsx b/client/src/features/sessionsV2/SessionList/SessionCard.tsx index a8fbbef1e3..ea593f1354 100644 --- a/client/src/features/sessionsV2/SessionList/SessionCard.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionCard.tsx @@ -40,7 +40,7 @@ interface SessionCardProps { } export default function SessionCard({ project, session }: SessionCardProps) { const { data: resourcePools } = useGetResourcePoolsQuery( - session ? {} : skipToken + session ? {} : skipToken, ); const currentSessionClassId = session?.resource_class_id; const userLauncherClass = useMemo( @@ -48,7 +48,7 @@ export default function SessionCard({ project, session }: SessionCardProps) { resourcePools ?.flatMap((pool) => pool.classes) .find((c) => c.id == currentSessionClassId), - [currentSessionClassId, resourcePools] + [currentSessionClassId, resourcePools], ); if (!session) return null; diff --git a/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx index 82da858478..24dd1a5f22 100644 --- a/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx +++ b/client/src/features/sessionsV2/SessionList/SessionLauncherCard.tsx @@ -86,12 +86,12 @@ 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; @@ -101,7 +101,7 @@ export default function SessionLauncherCard({ : skipToken, { pollingInterval: 1_000, - } + }, ); const otherLauncherActions = launcher && @@ -132,7 +132,7 @@ export default function SessionLauncherCard({ useGetSessionsImagesQuery( environment?.container_image != null ? { imageUrl: environment.container_image } - : skipToken + : skipToken, ); const { data: resourcePools, isLoading: isLoadingResourcePools } = @@ -144,10 +144,10 @@ export default function SessionLauncherCard({ return { resourcePool: undefined, resourceClass: undefined }; } const resourcePool = resourcePools.find(({ classes }) => - classes.some(({ id }) => id === launcher.resource_class_id) + classes.some(({ id }) => id === launcher.resource_class_id), ); const resourceClass = resourcePool?.classes.find( - ({ id }) => id === launcher.resource_class_id + ({ id }) => id === launcher.resource_class_id, ); return { resourcePool, resourceClass }; }, [launcher?.resource_class_id, resourcePools]); @@ -158,7 +158,7 @@ export default function SessionLauncherCard({ styles.SessionLauncherCard, "cursor-pointer", "shadow-none", - "rounded-0" + "rounded-0", )} data-cy="session-launcher-item" onClick={toggleSessionView} @@ -270,8 +270,8 @@ export default function SessionLauncherCard({ lastBuild?.status === "succeeded" ? lastBuild?.result?.completed_at : lastSuccessfulBuild?.status === "succeeded" - ? lastSuccessfulBuild?.result?.completed_at - : undefined + ? lastSuccessfulBuild?.result?.completed_at + : undefined } /> @@ -296,7 +296,7 @@ export default function SessionLauncherCard({ "d-flex", "flex-column", "align-items-end", - "gap-2" + "gap-2", )} > { +interface SaveCloudStorageProps extends Omit< + StartSessionFromLauncherProps, + "project" +> { startSessionOptionsV2: StartSessionOptionsV2; } @@ -98,7 +100,7 @@ function SaveCloudStorage({ }, [startSessionOptionsV2.dataConnectors]); const [results, setResults] = useState( - credentialsToSave.map(() => StatusStepProgressBar.WAITING) + credentialsToSave.map(() => StatusStepProgressBar.WAITING), ); const [index, setIndex] = useState(0); @@ -132,7 +134,7 @@ function SaveCloudStorage({ ([key, value]) => ({ name: key, value, - }) + }), ), }); }, [credentialsToSave, index, saveCredentials]); @@ -174,13 +176,13 @@ function SaveCloudStorage({ } if (index >= credentialsToSave.length) { const cloudStorageConfigs = startSessionOptionsV2.dataConnectors?.map( - (cs) => storageDefinitionAfterSavingCredentialsFromConfig(cs) + (cs) => storageDefinitionAfterSavingCredentialsFromConfig(cs), ); if (cloudStorageConfigs) dispatch( startSessionOptionsV2Slice.actions.setDataConnectorsOverrides( - cloudStorageConfigs - ) + cloudStorageConfigs, + ), ); } }, [ @@ -195,7 +197,7 @@ function SaveCloudStorage({
startSessionOptionsV2 + ({ startSessionOptionsV2 }) => startSessionOptionsV2, ); const [ startSessionV2, @@ -228,7 +230,7 @@ function SessionStarting({ launcher, project }: StartSessionFromLauncherProps) { disk_storage: startSessionOptionsV2.storage, resource_class_id: startSessionOptionsV2.sessionClass, data_connectors_overrides: startSessionOptionsV2.dataConnectors?.flatMap( - dataConnectorsOverrideFromConfig + dataConnectorsOverrideFromConfig, ), env_variable_overrides: Array.from(searchParams) .filter(([name]) => validateEnvVariableName(name) === true) @@ -295,8 +297,8 @@ function SessionStarting({ launcher, project }: StartSessionFromLauncherProps) { status: error ? StatusStepProgressBar.FAILED : isLoadingStartSession - ? StatusStepProgressBar.EXECUTING - : StatusStepProgressBar.READY, + ? StatusStepProgressBar.EXECUTING + : StatusStepProgressBar.READY, step: "Requesting session", }, ]); @@ -314,7 +316,7 @@ function SessionStarting({ launcher, project }: StartSessionFromLauncherProps) {
[ storageSecretNameToFieldName({ name: field }), true, - ]) + ]), ) : {}; if (sensitiveFields.every((key) => credentialFieldDict[key] != null)) { return false; } return Object.values(config.sensitiveFieldValues).some( - (value) => value === "" + (value) => value === "", ); } function shouldCloudStorageSaveCredentials( - config: SessionStartDataConnectorConfiguration + config: SessionStartDataConnectorConfiguration, ) { return config.saveCredentials; } -interface StartSessionWithCloudStorageModalProps - extends StartSessionFromLauncherProps { +interface StartSessionWithCloudStorageModalProps extends StartSessionFromLauncherProps { dataConnectors: SessionStartDataConnectorConfiguration[]; } @@ -383,17 +384,17 @@ function StartSessionWithCloudStorageModal({ const configsWithCredentials = useMemo( () => dataConnectors.filter( - (config) => !doesCloudStorageNeedCredentials(config) + (config) => !doesCloudStorageNeedCredentials(config), ), - [dataConnectors] + [dataConnectors], ); const configsNeedingCredentials = useMemo( () => dataConnectors.filter((config) => - doesCloudStorageNeedCredentials(config) + doesCloudStorageNeedCredentials(config), ), - [dataConnectors] + [dataConnectors], ); useEffect(() => { @@ -413,11 +414,11 @@ function StartSessionWithCloudStorageModal({ ]; dispatch( startSessionOptionsV2Slice.actions.setDataConnectorsOverrides( - cloudStorageConfigs - ) + cloudStorageConfigs, + ), ); }, - [dispatch, configsWithCredentials] + [dispatch, configsWithCredentials], ); const steps = [ @@ -447,7 +448,7 @@ function StartSessionWithCloudStorageModal({
startSessionOptionsV2 + ({ startSessionOptionsV2 }) => startSessionOptionsV2, ); const { @@ -510,11 +511,11 @@ function StartSessionFromLauncher({ }); const needsCredentials = startSessionOptionsV2.dataConnectors?.some( - doesCloudStorageNeedCredentials + doesCloudStorageNeedCredentials, ); const shouldSaveCredentials = startSessionOptionsV2.dataConnectors?.some( - shouldCloudStorageSaveCredentials + shouldCloudStorageSaveCredentials, ); const allDataFetched = @@ -659,7 +660,7 @@ function StartSessionFromLauncher({
launchers?.find(({ id }) => id === launcherId), - [launcherId, launchers] + [launcherId, launchers], ); //? We do not start the session while the logged out prompt is displayed. const { isLoggedIn, shouldBeLoggedIn } = useAppSelector( - ({ loginState }) => loginState + ({ loginState }) => loginState, ); const isShowingLoggedOutPrompt = !isLoggedIn && shouldBeLoggedIn; @@ -740,8 +741,7 @@ export default function SessionStartPage() { return ; } -interface StartSessionWithSessionSecretsModalProps - extends StartSessionFromLauncherProps { +interface StartSessionWithSessionSecretsModalProps extends StartSessionFromLauncherProps { sessionSecretSlotsWithSecrets: SessionSecretSlotWithSecret[]; } @@ -751,7 +751,7 @@ function StartSessionWithSessionSecretsModal({ sessionSecretSlotsWithSecrets, }: StartSessionWithSessionSecretsModalProps) { const startSessionOptionsV2 = useAppSelector( - ({ startSessionOptionsV2 }) => startSessionOptionsV2 + ({ startSessionOptionsV2 }) => startSessionOptionsV2, ); const showModal = !startSessionOptionsV2.userSecretsReady; @@ -774,7 +774,7 @@ function StartSessionWithSessionSecretsModal({
startSessionOptionsV2 + ({ startSessionOptionsV2 }) => startSessionOptionsV2, ); const showModal = !startSessionOptionsV2.imageReady; @@ -822,7 +822,7 @@ function StartSessionImageModal({
startSessionOptionsV2 + ({ startSessionOptionsV2 }) => startSessionOptionsV2, ); const showModal = !startSessionOptionsV2.repositoriesReady; @@ -870,7 +870,7 @@ function StartSessionRepositoriesModal({
pool.classes) .find((c) => c.id == currentSessionClassId), - [currentSessionClassId, resourcePools] + [currentSessionClassId, resourcePools], ); const quotaEnforced = false; // TODO: Pass the actual value when available from the API return ( diff --git a/client/src/features/sessionsV2/StartSessionButton.tsx b/client/src/features/sessionsV2/StartSessionButton.tsx index 2d9ecc5cdf..6a2ecc7536 100644 --- a/client/src/features/sessionsV2/StartSessionButton.tsx +++ b/client/src/features/sessionsV2/StartSessionButton.tsx @@ -34,8 +34,10 @@ import { useGetSessionsImagesQuery } from "./api/sessionsV2.api"; import { UsageQuotaReachedLaunchButton } from "./components/SessionLauncherButtons"; import { CUSTOM_LAUNCH_SEARCH_PARAM } from "./session.constants"; -interface SessionStartDefaultActionButtonProps - extends Pick { +interface SessionStartDefaultActionButtonProps extends Pick< + StartSessionButtonProps, + "launcher" | "resourceClass" +> { force: boolean; isLaunchButtonDisabled: boolean; startUrl: string; @@ -68,7 +70,7 @@ function SessionStartDefaultActionButton({ "btn-sm", force ? "btn-outline-primary" : "btn-primary", "rounded-end-0", - isLaunchButtonDisabled && "disabled" + isLaunchButtonDisabled && "disabled", )} to={startUrl} data-cy="start-session-button" @@ -111,7 +113,7 @@ export default function StartSessionButton({ launcherId: launcher.id, namespace, slug, - } + }, ); const environment = launcher?.environment; const isExternalImageEnvironment = @@ -122,7 +124,7 @@ export default function StartSessionButton({ environment.environment_kind === "CUSTOM" && environment.container_image ? { imageUrl: environment.container_image } - : skipToken + : skipToken, ); const { params } = useContext(AppContext); const imageBuildersEnabled = @@ -130,11 +132,11 @@ export default function StartSessionButton({ const { data: builds } = useGetBuildsQuery( imageBuildersEnabled && environment.environment_image_source === "build" ? { environmentId: environment.id } - : skipToken + : skipToken, ); const hasSuccessfulBuild = builds?.find( - (build) => build.status === "succeeded" + (build) => build.status === "succeeded", ); const force = isExternalImageEnvironment && !isLoading && !data?.accessible; diff --git a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx index 0c528b05b6..1c0b939bb5 100644 --- a/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx +++ b/client/src/features/sessionsV2/components/SessionButton/ActiveSessionButton.tsx @@ -74,11 +74,10 @@ import { import ShutdownSessionContent from "../SessionModals/ShoutdownSessionContent"; import { SessionRowResourceRequests } from "../SessionsList"; -interface ActiveSessionDefaultButtonProps - extends Pick< - ActiveSessionButtonProps, - "usageLimit" | "session" | "showSessionUrl" - > { +interface ActiveSessionDefaultButtonProps extends Pick< + ActiveSessionButtonProps, + "usageLimit" | "session" | "showSessionUrl" +> { isHibernating: boolean; isResuming: boolean; isStopping: boolean; @@ -105,12 +104,12 @@ function ActiveSessionDefaultButton({ 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( "btn", @@ -119,7 +118,7 @@ function ActiveSessionDefaultButton({ "start-session-button", "py-1", "px-2", - "btn-outline-primary" + "btn-outline-primary", ); const { data: user } = useGetUserQueryState(); const isUserLoggedIn = !!user?.isLoggedIn; @@ -413,7 +412,7 @@ export default function ActiveSessionButton({ const [showModalStopSession, setShowModalStopSession] = useState(false); const toggleStopSession = useCallback( () => setShowModalStopSession((show) => !show), - [] + [], ); // Handle modifying session @@ -432,7 +431,7 @@ export default function ActiveSessionButton({ }); } }, - [modifySession, onResumeSession, session.name, session.status.state] + [modifySession, onResumeSession, session.name, session.status.state], ); useEffect(() => { if (errorModifySession) { @@ -446,19 +445,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 defaultAction = ( @@ -712,7 +711,7 @@ function ModifySessionModalContent({ toggleModal(); }; }, - [currentSessionClass, onModifySession, toggleModal] + [currentSessionClass, onModifySession, toggleModal], ); useEffect(() => { @@ -756,7 +755,7 @@ function ModifySessionModalContent({ resourcePools ?.flatMap((pool) => pool.classes) .find((c) => c.id == currentSessionClass?.id), - [currentSessionClass, resourcePools] + [currentSessionClass, resourcePools], ); return ( diff --git a/client/src/features/sessionsV2/components/SessionClassSelector.tsx b/client/src/features/sessionsV2/components/SessionClassSelector.tsx index 8713e61531..8e8943421c 100644 --- a/client/src/features/sessionsV2/components/SessionClassSelector.tsx +++ b/client/src/features/sessionsV2/components/SessionClassSelector.tsx @@ -67,13 +67,13 @@ function SessionClassThresholds({ idleThreshold: pool.idle_threshold ?? defaultIdle ?? 0, hibernationThreshold: pool.hibernation_threshold ?? defaultHibernation ?? 0, - })) + })), ); }, [defaultHibernation, defaultIdle, resourcePools]); const currentClassThresholds = useMemo( () => classesThresholds.find((c) => c.classId === currentSessionClass?.id), - [classesThresholds, currentSessionClass] + [classesThresholds, currentSessionClass], ); if ( @@ -118,7 +118,7 @@ interface OptionGroup extends GroupBase { const makeGroupedOptions = ( resourcePools: ResourcePoolWithIdFiltered[], - defaultIdleThreshold?: number + defaultIdleThreshold?: number, ): OptionGroup[] => resourcePools.map((pool) => ({ label: pool.name, @@ -145,9 +145,9 @@ const SessionClassSelector = ({ () => makeGroupedOptions( resourcePools, - nbVersion?.defaultCullingThresholds?.registered.idle + nbVersion?.defaultCullingThresholds?.registered.idle, ), - [resourcePools, nbVersion] + [resourcePools, nbVersion], ); return ( @@ -192,7 +192,7 @@ const selectComponentsV2: SelectComponentsConfig< ); }, Option: ( - props: OptionProps + props: OptionProps, ) => { const { data: sessionClass } = props; return ( @@ -202,7 +202,7 @@ const selectComponentsV2: SelectComponentsConfig< ); }, SingleValue: ( - props: SingleValueProps + props: SingleValueProps, ) => { const { data: sessionClass } = props; return ( @@ -212,7 +212,7 @@ const selectComponentsV2: SelectComponentsConfig< ); }, GroupHeading: ( - props: GroupHeadingProps + props: GroupHeadingProps, ) => { return ( @@ -224,7 +224,7 @@ const selectComponentsV2: SelectComponentsConfig< ); }, MenuList: ( - props: MenuListProps + props: MenuListProps, ) => { return ( cx("pe-2"), groupHeading: () => cx("px-2", styles.groupHeading), @@ -264,7 +264,7 @@ const selectClassNamesV2: ClassNamesConfig< "p-2", styles.option, isFocused && styles.optionIsFocused, - !isFocused && isSelected && styles.optionIsSelected + !isFocused && isSelected && styles.optionIsSelected, ), placeholder: () => cx("px-2"), singleValue: () => cx("d-grid", "gap-1", "px-2", styles.singleValue), @@ -285,7 +285,7 @@ const OptionOrSingleValueContent = ({ "text-wrap", "text-break", styles.label, - canBeUsed && styles.labelMatches + canBeUsed && styles.labelMatches, ); const detailValueClassName = cx(styles.detail, styles.detailValue); const detailLabelClassName = cx(styles.detail, styles.detailLabel); diff --git a/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx b/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx index 558a89441a..511c8daba1 100644 --- a/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx +++ b/client/src/features/sessionsV2/components/SessionLauncherButtons.tsx @@ -64,11 +64,10 @@ export function UsageQuotaReachedLaunchButton() { ); } -interface SessionLauncherDefaultAction - extends Pick< - SessionLauncherButtonsProps, - "hasSession" | "launcher" | "namespace" | "slug" - > { +interface SessionLauncherDefaultAction extends Pick< + SessionLauncherButtonsProps, + "hasSession" | "launcher" | "namespace" | "slug" +> { displayBuildActions: boolean; displayLaunchSession: boolean; imageCheckData: ImageCheckResponse | undefined; @@ -108,7 +107,7 @@ function SessionLauncherDefaultAction({ launcherId: launcher.id, namespace, slug, - } + }, ); if (imageCheckLoading) @@ -135,7 +134,7 @@ function SessionLauncherDefaultAction({ "btn-sm", hasSession ? "btn-outline-primary" : "btn-primary", hasSession && "disabled", - displayBuildActions ? "rounded-0" : "rounded-end-0" + displayBuildActions ? "rounded-0" : "rounded-end-0", )} to={startUrl} data-cy="start-session-button" @@ -214,12 +213,12 @@ export function SessionLauncherButtons({ launcherId: launcher.id, namespace, slug, - } + }, ); const { data, isLoading } = useGetSessionsImagesQuery( environment.environment_kind === "CUSTOM" && environment.container_image ? { imageUrl: environment.container_image } - : skipToken + : skipToken, ); const displayLaunchSession = !isCodeEnvironment || diff --git a/client/src/features/sessionsV2/components/SessionModals/SelectResourceClass.tsx b/client/src/features/sessionsV2/components/SessionModals/SelectResourceClass.tsx index a289a527f1..a89d0ce3e3 100644 --- a/client/src/features/sessionsV2/components/SessionModals/SelectResourceClass.tsx +++ b/client/src/features/sessionsV2/components/SessionModals/SelectResourceClass.tsx @@ -141,7 +141,7 @@ export function SelectResourceClassModal({ resourcePools ?.flatMap((pool) => pool.classes) .find((c) => c.id == launcherClass?.id), - [launcherClass, resourcePools] + [launcherClass, resourcePools], ); const resourceDetails = diff --git a/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx b/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx index 5616a68692..1df46a4710 100644 --- a/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx +++ b/client/src/features/sessionsV2/components/SessionStatus/SessionStatus.tsx @@ -446,7 +446,7 @@ function SessionStatusV2TextQuotaInformation({ const pollingInterval = 60 * 1000; // 1 minute const { data: resourcePools } = useGetResourcePoolsQuery( {}, - { pollingInterval } + { pollingInterval }, ); const resourceClass = resourcePools ?.flatMap((pool) => pool.classes) diff --git a/client/src/features/sessionsV2/session.utils.tsx b/client/src/features/sessionsV2/session.utils.tsx index c1ef812593..503a07be42 100644 --- a/client/src/features/sessionsV2/session.utils.tsx +++ b/client/src/features/sessionsV2/session.utils.tsx @@ -425,7 +425,7 @@ export function isImageCompatibleWith( export function usageAvailableString( usageAvailableHours: number | undefined, - short = false + short = false, ): string | null { if (usageAvailableHours == null) return null; if (short) return `${usageAvailableHours}h available`; diff --git a/tests/cypress/e2e/projectV2Session.spec.ts b/tests/cypress/e2e/projectV2Session.spec.ts index c57c4f4cfd..678bcb1cf0 100644 --- a/tests/cypress/e2e/projectV2Session.spec.ts +++ b/tests/cypress/e2e/projectV2Session.spec.ts @@ -1222,13 +1222,13 @@ describe("launch sessions with resource quotas", () => { cy.getDataCy("session-name").should("contain.text", "Session-custom"); cy.getDataCy("start-session-button").should( "contain.text", - "Quota Reached" + "Quota Reached", ); }) .click(); cy.getDataCy("session-view-resource-class-availability").should( "contain.text", - "Usage quota for this resource pool has been reached" + "Usage quota for this resource pool has been reached", ); cy.getDataCy("get-back-session-view").click(); @@ -1251,7 +1251,7 @@ describe("launch sessions with resource quotas", () => { cy.get(".modal-body").within(() => { cy.get("p").should( "contain.text", - "Please select one of your available resource classes to continue." + "Please select one of your available resource classes to continue.", ); // cy.get("#react-select-5-placeholder").click(); cy.contains("Select...").should("be.visible").click(); @@ -1260,7 +1260,7 @@ describe("launch sessions with resource quotas", () => { }); cy.get("button").contains("Continue").click(); cy.contains("200h of compute time until quota is used").should( - "be.visible" + "be.visible", ); }); diff --git a/tests/cypress/support/renkulab-fixtures/dataServices.ts b/tests/cypress/support/renkulab-fixtures/dataServices.ts index d9246b1d7f..dd2a873af4 100644 --- a/tests/cypress/support/renkulab-fixtures/dataServices.ts +++ b/tests/cypress/support/renkulab-fixtures/dataServices.ts @@ -43,8 +43,10 @@ interface ResourcePoolIdFixture extends SimpleFixture { resourcePoolId: number; } -interface PutResourcePoolLimitsFixture - extends Omit { +interface PutResourcePoolLimitsFixture extends Omit< + ResourcePoolIdFixture, + "fixture" +> { resourcePoolId: number; totalLimit: number; userLimit: number; @@ -123,7 +125,7 @@ export function DataServices(Parent: T) { cy.intercept( "GET", `/api/data/resource_pools/${resourcePoolId}/limits`, - { body: limits } + { body: limits }, ).as(name); }); return this; @@ -144,7 +146,7 @@ export function DataServices(Parent: T) { expect(req.body.total_limit).to.equal(totalLimit); expect(req.body.user_limit).to.equal(userLimit); req.reply({ body: limits }); - } + }, ).as(name); return this; } diff --git a/tests/cypress/support/renkulab-fixtures/sessions.ts b/tests/cypress/support/renkulab-fixtures/sessions.ts index 22b00b7130..b1b8f53f4b 100644 --- a/tests/cypress/support/renkulab-fixtures/sessions.ts +++ b/tests/cypress/support/renkulab-fixtures/sessions.ts @@ -46,11 +46,11 @@ export function Sessions(Parent: T) { const status = session.status as Record; if (status.state == "hibernated") { status.will_delete_at = new Date( - fiveMinutesAgo.getTime() + 24 * 60 * 60 * 1000 + fiveMinutesAgo.getTime() + 24 * 60 * 60 * 1000, ).toISOString(); } return session; - } + }, ); // eslint-disable-next-line max-nested-callbacks cy.intercept("GET", "/api/data/sessions*", (req) => {