diff --git a/client/src/features/sessionsV2/SessionView/EnvironmentCard.tsx b/client/src/features/sessionsV2/SessionView/EnvironmentCard.tsx index 0af553b4c..439ed24d2 100644 --- a/client/src/features/sessionsV2/SessionView/EnvironmentCard.tsx +++ b/client/src/features/sessionsV2/SessionView/EnvironmentCard.tsx @@ -16,20 +16,28 @@ * limitations under the License. */ +import { skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; -import { ReactNode } from "react"; +import { ReactNode, useEffect } from "react"; import { + Bricks, CircleFill, Clock, Globe2, Link45deg, - Tools, } from "react-bootstrap-icons"; import { Badge, Card, CardBody, Col, Row } from "reactstrap"; +import { Loader } from "../../../components/Loader"; +import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; import { ErrorLabel } from "../../../components/formlabels/FormLabels"; +import useAppDispatch from "../../../utils/customHooks/useAppDispatch.hook"; import { toHumanDateTime } from "../../../utils/helpers/DateTimeUtils"; -import type { SessionLauncher } from "../api/sessionLaunchersV2.api"; +import type { Build, SessionLauncher } from "../api/sessionLaunchersV2.api"; +import { + sessionLaunchersV2Api, + useGetEnvironmentsByEnvironmentIdBuildsQuery as useGetBuildsQuery, +} from "../api/sessionLaunchersV2.api"; import { BUILDER_IMAGE_NOT_READY_VALUE } from "../session.constants"; import { safeStringify } from "../session.utils"; @@ -57,7 +65,7 @@ export function EnvironmentCard({ launcher }: { launcher: SessionLauncher }) { > ) : environment.environment_image_source === "build" ? ( <> - + Built by RenkuLab > ) : ( @@ -163,6 +171,39 @@ function CustomBuildEnvironmentValues({ }) { const { environment } = launcher; + const { + data: builds, + isLoading, + error, + } = useGetBuildsQuery( + environment.environment_image_source === "build" + ? { environmentId: environment.id } + : skipToken + ); + + const lastBuild = builds?.at(0); + + sessionLaunchersV2Api.endpoints.getEnvironmentsByEnvironmentIdBuilds.useQuerySubscription( + lastBuild?.status === "in_progress" + ? { environmentId: environment.id } + : skipToken, + { + pollingInterval: 1_000, + } + ); + + // Invalidate launchers if the container image is not the same as the + // image from the last successful build + const dispatch = useAppDispatch(); + useEffect(() => { + if ( + lastBuild?.status === "succeeded" && + lastBuild.result.image !== launcher.environment.container_image + ) { + dispatch(sessionLaunchersV2Api.endpoints.invalidateLaunchers.initiate()); + } + }, [dispatch, lastBuild, launcher.environment.container_image]); + if (environment.environment_image_source !== "build") { return null; } @@ -179,6 +220,32 @@ function CustomBuildEnvironmentValues({ )} + + {isLoading ? ( + + + Loading build status... + + ) : error || !builds ? ( + + Error: could not load build status + {error && } + + ) : lastBuild == null ? ( + + This session environment does not have a build yet. + + ) : ( + + + Last build status: + + + + + + )} + + + {environment.container_image !== BUILDER_IMAGE_NOT_READY_VALUE && ( + + )} > ); } @@ -279,3 +350,39 @@ function NotReadyStatusBadge() { ); } + +interface BuildStatusBadgeProps { + status: Build["status"]; +} + +function BuildStatusBadge({ status }: BuildStatusBadgeProps) { + const badgeIcon = + status === "in_progress" ? ( + + ) : ( + + ); + + const badgeText = + status === "in_progress" + ? "In progress" + : status === "cancelled" + ? "Cancelled" + : status === "succeeded" + ? "Succeeded" + : "Failed"; + + const badgeColorClasses = + status === "in_progress" + ? ["border-warning", "bg-warning-subtle", "text-warning-emphasis"] + : status === "succeeded" + ? ["border-success", "bg-success-subtle", "text-success-emphasis"] + : ["border-danger", "bg-danger-subtle", "text-danger-emphasis"]; + + return ( + + {badgeIcon} + {badgeText && {badgeText}} + + ); +} diff --git a/client/src/features/sessionsV2/SessionsV2.tsx b/client/src/features/sessionsV2/SessionsV2.tsx index 7b3ac7e5c..cb7a2df64 100644 --- a/client/src/features/sessionsV2/SessionsV2.tsx +++ b/client/src/features/sessionsV2/SessionsV2.tsx @@ -16,9 +16,11 @@ * limitations under the License. */ +import { SerializedError } from "@reduxjs/toolkit"; +import { FetchBaseQueryError, skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; import { useCallback, useMemo, useState } from "react"; -import { Pencil, PlayCircle, Trash } from "react-bootstrap-icons"; +import { Bricks, Pencil, PlayCircle, Trash, XLg } from "react-bootstrap-icons"; import { generatePath } from "react-router-dom-v5-compat"; import { Badge, @@ -28,11 +30,18 @@ import { CardHeader, DropdownItem, ListGroup, + ModalBody, + ModalFooter, + ModalHeader, } from "reactstrap"; import { Loader } from "../../components/Loader"; import { ButtonWithMenuV2 } from "../../components/buttons/Button"; -import { RtkErrorAlert } from "../../components/errors/RtkErrorAlert"; +import { + RtkErrorAlert, + RtkOrNotebooksError, +} from "../../components/errors/RtkErrorAlert"; +import ScrollableModal from "../../components/modal/ScrollableModal"; import { ABSOLUTE_ROUTES } from "../../routing/routes.constants"; import useLocationHash from "../../utils/customHooks/useLocationHash.hook"; import useProjectPermissions from "../ProjectPageV2/utils/useProjectPermissions.hook"; @@ -44,7 +53,11 @@ import SessionItem from "./SessionList/SessionItem"; import { SessionItemDisplay } from "./SessionList/SessionItemDisplay"; import { SessionView } from "./SessionView/SessionView"; import type { SessionLauncher } from "./api/sessionLaunchersV2.api"; -import { useGetProjectsByProjectIdSessionLaunchersQuery as useGetProjectSessionLaunchersQuery } from "./api/sessionLaunchersV2.api"; +import { + useGetEnvironmentsByEnvironmentIdBuildsQuery as useGetBuildsQuery, + useGetProjectsByProjectIdSessionLaunchersQuery as useGetProjectSessionLaunchersQuery, + usePostEnvironmentsByEnvironmentIdBuildsMutation as usePostBuildMutation, +} from "./api/sessionLaunchersV2.api"; import { useGetSessionsQuery as useGetSessionsQueryV2 } from "./api/sessionsV2.api"; import UpdateSessionLauncherModal from "./components/SessionModals/UpdateSessionLauncherModal"; import { SessionV2 } from "./sessionsV2.types"; @@ -192,6 +205,22 @@ export function SessionV2Actions({ setIsDeleteOpen((open) => !open); }, []); + const [postBuild, result] = usePostBuildMutation(); + const triggerBuild = useCallback(() => { + postBuild({ environmentId: launcher.environment.id }); + }, [launcher.environment.id, postBuild]); + + const { data: builds } = useGetBuildsQuery( + launcher.environment.environment_image_source === "build" + ? { environmentId: launcher.environment.id } + : skipToken + ); + const hasInProgressBuild = useMemo( + () => + builds ? !!builds.find(({ status }) => status === "in_progress") : false, + [builds] + ); + const defaultAction = ( ); + const rebuildAction = launcher.environment.environment_kind === "CUSTOM" && + launcher.environment.environment_image_source === "build" && ( + + + Rebuild session image + + ); + return ( - - - + + - - Delete - - {" "} - - - > - } - requestedPermission="write" - userPermissions={permissions} - /> + + + Delete + + {rebuildAction} + + + + > + } + requestedPermission="write" + userPermissions={permissions} + /> + + > ); } @@ -282,3 +327,33 @@ function OrphanSession({ session, project }: OrphanSessionProps) { > ); } + +interface RebuildFailedModalProps { + error: FetchBaseQueryError | SerializedError | undefined; + reset: () => void; +} + +function RebuildFailedModal({ error, reset }: RebuildFailedModalProps) { + return ( + + + Error: could not rebuild session image + + + + + + + + Close + + + + ); +} diff --git a/client/src/features/sessionsV2/api/sessionLaunchersV2.api.ts b/client/src/features/sessionsV2/api/sessionLaunchersV2.api.ts index 756aa8bba..6502d6baf 100644 --- a/client/src/features/sessionsV2/api/sessionLaunchersV2.api.ts +++ b/client/src/features/sessionsV2/api/sessionLaunchersV2.api.ts @@ -43,8 +43,8 @@ const withFixedEndpoints = sessionLaunchersV2GeneratedApi.injectEndpoints({ }); // Adds tag handling for cache management -export const sessionLaunchersV2Api = withFixedEndpoints.enhanceEndpoints({ - addTagTypes: ["Environment", "Launcher"], +const withTagHandling = withFixedEndpoints.enhanceEndpoints({ + addTagTypes: ["Environment", "Launcher", "Build"], endpoints: { getEnvironments: { providesTags: (result) => @@ -83,9 +83,39 @@ export const sessionLaunchersV2Api = withFixedEndpoints.enhanceEndpoints({ ] : ["Launcher"], }, + getBuildsByBuildId: { + providesTags: (result) => + result ? [{ id: result.id, type: "Build" }, "Build"] : ["Build"], + }, + postEnvironmentsByEnvironmentIdBuilds: { + invalidatesTags: ["Build"], + }, + patchBuildsByBuildId: { + invalidatesTags: (result) => + result ? [{ id: result.id, type: "Build" }] : ["Build"], + }, + getEnvironmentsByEnvironmentIdBuilds: { + providesTags: (result) => + result + ? [ + ...result.map(({ id }) => ({ id, type: "Build" as const })), + "Build", + ] + : ["Build"], + }, }, }); +// Adds tag invalidation endpoints +export const sessionLaunchersV2Api = withTagHandling.injectEndpoints({ + endpoints: (build) => ({ + invalidateLaunchers: build.mutation({ + queryFn: () => ({ data: null }), + invalidatesTags: ["Launcher"], + }), + }), +}); + export const { // "environments" hooks useGetEnvironmentsQuery, @@ -95,6 +125,11 @@ export const { usePatchSessionLaunchersByLauncherIdMutation, useDeleteSessionLaunchersByLauncherIdMutation, useGetProjectsByProjectIdSessionLaunchersQuery, + // "builds" hooks + useGetBuildsByBuildIdQuery, + usePostEnvironmentsByEnvironmentIdBuildsMutation, + usePatchBuildsByBuildIdMutation, + useGetEnvironmentsByEnvironmentIdBuildsQuery, } = sessionLaunchersV2Api; export type * from "./sessionLaunchersV2.generated-api"; diff --git a/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts b/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts index 532afd2c6..b77b6f770 100644 --- a/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts +++ b/client/src/features/sessionsV2/api/sessionLaunchersV2.generated-api.ts @@ -96,6 +96,48 @@ const injectedRtkApi = api.injectEndpoints({ url: `/projects/${queryArg.projectId}/session_launchers`, }), }), + getBuildsByBuildId: build.query< + GetBuildsByBuildIdApiResponse, + GetBuildsByBuildIdApiArg + >({ + query: (queryArg) => ({ url: `/builds/${queryArg.buildId}` }), + }), + patchBuildsByBuildId: build.mutation< + PatchBuildsByBuildIdApiResponse, + PatchBuildsByBuildIdApiArg + >({ + query: (queryArg) => ({ + url: `/builds/${queryArg.buildId}`, + method: "PATCH", + body: queryArg.buildPatch, + }), + }), + getBuildsByBuildIdLogs: build.query< + GetBuildsByBuildIdLogsApiResponse, + GetBuildsByBuildIdLogsApiArg + >({ + query: (queryArg) => ({ + url: `/builds/${queryArg.buildId}/logs`, + params: { max_lines: queryArg.maxLines }, + }), + }), + getEnvironmentsByEnvironmentIdBuilds: build.query< + GetEnvironmentsByEnvironmentIdBuildsApiResponse, + GetEnvironmentsByEnvironmentIdBuildsApiArg + >({ + query: (queryArg) => ({ + url: `/environments/${queryArg.environmentId}/builds`, + }), + }), + postEnvironmentsByEnvironmentIdBuilds: build.mutation< + PostEnvironmentsByEnvironmentIdBuildsApiResponse, + PostEnvironmentsByEnvironmentIdBuildsApiArg + >({ + query: (queryArg) => ({ + url: `/environments/${queryArg.environmentId}/builds`, + method: "POST", + }), + }), }), overrideExisting: false, }); @@ -158,6 +200,34 @@ export type GetProjectsByProjectIdSessionLaunchersApiResponse = export type GetProjectsByProjectIdSessionLaunchersApiArg = { projectId: Ulid; }; +export type GetBuildsByBuildIdApiResponse = + /** status 200 The container image build */ Build; +export type GetBuildsByBuildIdApiArg = { + buildId: Ulid; +}; +export type PatchBuildsByBuildIdApiResponse = + /** status 200 The updated container image build */ Build; +export type PatchBuildsByBuildIdApiArg = { + buildId: Ulid; + buildPatch: BuildPatch; +}; +export type GetBuildsByBuildIdLogsApiResponse = + /** status 200 The build logs */ BuildLogs; +export type GetBuildsByBuildIdLogsApiArg = { + buildId: Ulid; + /** The maximum number of most-recent lines to return for each container */ + maxLines?: number; +}; +export type GetEnvironmentsByEnvironmentIdBuildsApiResponse = + /** status 200 List of container image builds */ BuildList; +export type GetEnvironmentsByEnvironmentIdBuildsApiArg = { + environmentId: Ulid; +}; +export type PostEnvironmentsByEnvironmentIdBuildsApiResponse = + /** status 201 The build was created */ Build; +export type PostEnvironmentsByEnvironmentIdBuildsApiArg = { + environmentId: Ulid; +}; export type Ulid = string; export type SessionName = string; export type CreationDate = string; @@ -311,6 +381,33 @@ export type SessionLauncherPatch = { disk_storage?: DiskStoragePatch; environment?: EnvironmentPatchInLauncher | EnvironmentIdOnlyPatch; }; +export type BuildCommonPart = { + id: Ulid; + environment_id: Ulid; + created_at: CreationDate; +}; +export type BuildNotCompletedPart = { + status: "in_progress" | "failed" | "cancelled"; +}; +export type BuildResult = { + image: ContainerImage; + completed_at: CreationDate; + repository_url: string; + repository_git_commit_sha: string; +}; +export type BuildCompletedPart = { + status: "succeeded"; + result: BuildResult; +}; +export type Build = BuildCommonPart & + (BuildNotCompletedPart | BuildCompletedPart); +export type BuildPatch = { + status?: "cancelled"; +}; +export type BuildLogs = { + [key: string]: string; +}; +export type BuildList = Build[]; export const { useGetEnvironmentsQuery, usePostEnvironmentsMutation, @@ -323,4 +420,9 @@ export const { usePatchSessionLaunchersByLauncherIdMutation, useDeleteSessionLaunchersByLauncherIdMutation, useGetProjectsByProjectIdSessionLaunchersQuery, + useGetBuildsByBuildIdQuery, + usePatchBuildsByBuildIdMutation, + useGetBuildsByBuildIdLogsQuery, + useGetEnvironmentsByEnvironmentIdBuildsQuery, + usePostEnvironmentsByEnvironmentIdBuildsMutation, } = injectedRtkApi; diff --git a/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json b/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json index a6acfb17d..33a2b2fae 100644 --- a/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json +++ b/client/src/features/sessionsV2/api/sessionLaunchersV2.openapi.json @@ -386,6 +386,159 @@ }, "tags": ["session_launchers"] } + }, + "/builds/{build_id}": { + "parameters": [ + { + "in": "path", + "name": "build_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + } + ], + "get": { + "summary": "Get the details of a container image build", + "responses": { + "200": { + "description": "The container image build", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Build" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["builds"] + }, + "patch": { + "summary": "Update a container image build", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildPatch" + } + } + } + }, + "responses": { + "200": { + "description": "The updated container image build", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Build" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["builds"] + } + }, + "/builds/{build_id}/logs": { + "parameters": [ + { + "in": "path", + "name": "build_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + } + ], + "get": { + "summary": "Get the logs of a container image build", + "parameters": [ + { + "description": "The maximum number of most-recent lines to return for each container", + "in": "query", + "name": "max_lines", + "required": false, + "schema": { + "type": "integer", + "default": 250 + } + } + ], + "responses": { + "200": { + "description": "The build logs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildLogs" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["builds"] + } + }, + "/environments/{environment_id}/builds": { + "parameters": [ + { + "in": "path", + "name": "environment_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Ulid" + } + } + ], + "get": { + "summary": "Get a session environment's list of builds", + "responses": { + "200": { + "description": "List of container image builds", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildList" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["builds"] + }, + "post": { + "summary": "Create a new container image build", + "responses": { + "201": { + "description": "The build was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Build" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["builds"] + } } }, "components": { @@ -1040,6 +1193,128 @@ "description": "Whether this environment is archived and not for use in new projects or not", "default": false }, + "Build": { + "description": "A container image build", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BuildCommonPart" + }, + { + "oneOf": [ + { + "$ref": "#/components/schemas/BuildNotCompletedPart" + }, + { + "$ref": "#/components/schemas/BuildCompletedPart" + } + ] + } + ] + }, + "BuildCommonPart": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/Ulid" + }, + "environment_id": { + "$ref": "#/components/schemas/Ulid" + }, + "created_at": { + "$ref": "#/components/schemas/CreationDate" + } + }, + "required": ["id", "environment_id", "created_at"], + "additionalProperties": false + }, + "BuildNotCompletedPart": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["in_progress", "failed", "cancelled"], + "example": "in_progress" + } + }, + "required": ["status"], + "additionalProperties": false + }, + "BuildCompletedPart": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["succeeded"], + "example": "succeeded" + }, + "result": { + "$ref": "#/components/schemas/BuildResult" + } + }, + "required": ["status", "result"], + "additionalProperties": false + }, + "BuildList": { + "description": "A list of container image builds", + "type": "array", + "items": { + "$ref": "#/components/schemas/Build" + } + }, + "BuildPatch": { + "description": "The requested update of a container image build", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["cancelled"] + } + }, + "additionalProperties": false + }, + "BuildLogs": { + "description": "The logs of a container image build", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "example": { + "container-A": "Log line 1\nLog line 2", + "container-B": "Log line 1\nLog line 2" + } + }, + "BuildResult": { + "description": "The result of a container image build", + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/ContainerImage" + }, + "completed_at": { + "$ref": "#/components/schemas/CreationDate" + }, + "repository_url": { + "type": "string" + }, + "repository_git_commit_sha": { + "type": "string" + } + }, + "required": [ + "image", + "completed_at", + "repository_url", + "repository_git_commit_sha" + ], + "additionalProperties": false + }, + "BuildStatus": { + "description": "The status of a container image build", + "type": "string", + "enum": ["in_progress", "succeeded", "failed", "cancelled"], + "example": "succeeded" + }, "ErrorResponse": { "type": "object", "properties": {
Error: could not load build status