diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b686b2936594e..1b4c0cd09f46a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestHeaders } from "axios" +import axios from "axios" import dayjs from "dayjs" import * as Types from "./types" import { DeploymentConfig } from "./types" @@ -64,7 +64,7 @@ if (token !== null && token.getAttribute("content") !== null) { } } -const CONTENT_TYPE_JSON: AxiosRequestHeaders = { +const CONTENT_TYPE_JSON = { "Content-Type": "application/json", } @@ -974,6 +974,41 @@ export class MissingBuildParameters extends Error { } } +/** Steps to change the workspace version + * - Get the latest template to access the latest active version + * - Get the current build parameters + * - Get the template parameters + * - Update the build parameters and check if there are missed parameters for the new version + * - If there are missing parameters raise an error + * - Create a build with the version and updated build parameters + */ +export const changeWorkspaceVersion = async ( + workspace: TypesGen.Workspace, + templateVersionId: string, + newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], +): Promise => { + const [currentBuildParameters, templateParameters] = await Promise.all([ + getWorkspaceBuildParameters(workspace.latest_build.id), + getTemplateVersionRichParameters(templateVersionId), + ]) + + const missingParameters = getMissingParameters( + currentBuildParameters, + newBuildParameters, + templateParameters, + ) + + if (missingParameters.length > 0) { + throw new MissingBuildParameters(missingParameters) + } + + return postWorkspaceBuild(workspace.id, { + transition: "start", + template_version_id: templateVersionId, + rich_parameter_values: newBuildParameters, + }) +} + /** Steps to update the workspace * - Get the latest template to access the latest active version * - Get the current build parameters diff --git a/site/src/components/AvatarData/AvatarData.tsx b/site/src/components/AvatarData/AvatarData.tsx index c88ddc07af779..324a1d828060d 100644 --- a/site/src/components/AvatarData/AvatarData.tsx +++ b/site/src/components/AvatarData/AvatarData.tsx @@ -31,7 +31,7 @@ export const AvatarData: FC> = ({ > {avatar} - + {title} {subtitle && {subtitle}} @@ -42,6 +42,11 @@ export const AvatarData: FC> = ({ const useStyles = makeStyles((theme) => ({ root: { minHeight: theme.spacing(5), // Make it predictable for the skeleton + width: "100%", + }, + + info: { + width: "100%", }, title: { diff --git a/site/src/components/DropdownButton/ActionCtas.tsx b/site/src/components/DropdownButton/ActionCtas.tsx index 29b63c5fd6a6a..85031e8256ab0 100644 --- a/site/src/components/DropdownButton/ActionCtas.tsx +++ b/site/src/components/DropdownButton/ActionCtas.tsx @@ -4,6 +4,7 @@ import { makeStyles } from "@material-ui/core/styles" import BlockIcon from "@material-ui/icons/Block" import CloudQueueIcon from "@material-ui/icons/CloudQueue" import SettingsOutlined from "@material-ui/icons/SettingsOutlined" +import HistoryOutlined from "@material-ui/icons/HistoryOutlined" import CropSquareIcon from "@material-ui/icons/CropSquare" import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline" import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline" @@ -53,6 +54,23 @@ export const SettingsButton: FC> = ({ ) } +export const ChangeVersionButton: FC< + React.PropsWithChildren +> = ({ handleAction }) => { + const styles = useStyles() + + return ( + + ) +} + export const StartButton: FC> = ({ handleAction, }) => { diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx index 4d3f55d1d0399..3ca4ce7529c43 100644 --- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -26,7 +26,6 @@ export const UserAutocomplete: FC = ({ }) => { const styles = useStyles() const { t } = useTranslation("common") - const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false) const [searchState, sendSearch] = useMachine(searchUserMachine) const { searchResults } = searchState.context diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index a8479fa68cae0..ceb18f2483107 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -47,12 +47,14 @@ export interface WorkspaceProps { handleUpdate: () => void handleCancel: () => void handleSettings: () => void + handleChangeVersion: () => void isUpdating: boolean workspace: TypesGen.Workspace resources?: TypesGen.WorkspaceResource[] builds?: TypesGen.WorkspaceBuild[] canUpdateWorkspace: boolean canUpdateTemplate: boolean + canChangeVersions: boolean hideSSHButton?: boolean hideVSCodeDesktopButton?: boolean workspaceErrors: Partial> @@ -76,12 +78,14 @@ export const Workspace: FC> = ({ handleUpdate, handleCancel, handleSettings, + handleChangeVersion, workspace, isUpdating, resources, builds, canUpdateWorkspace, canUpdateTemplate, + canChangeVersions, workspaceErrors, hideSSHButton, hideVSCodeDesktopButton, @@ -142,6 +146,8 @@ export const Workspace: FC> = ({ handleUpdate={handleUpdate} handleCancel={handleCancel} handleSettings={handleSettings} + handleChangeVersion={handleChangeVersion} + canChangeVersions={canChangeVersions} isUpdating={isUpdating} /> diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index c4e9fde2d25d9..7a1c4e3aba14d 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next" import { WorkspaceStatus } from "../../api/typesGenerated" import { ActionLoadingButton, + ChangeVersionButton, DeleteButton, DisabledButton, SettingsButton, @@ -22,8 +23,10 @@ export interface WorkspaceActionsProps { handleUpdate: () => void handleCancel: () => void handleSettings: () => void + handleChangeVersion: () => void isUpdating: boolean children?: ReactNode + canChangeVersions: boolean } export const WorkspaceActions: FC = ({ @@ -35,7 +38,9 @@ export const WorkspaceActions: FC = ({ handleUpdate, handleCancel, handleSettings, + handleChangeVersion, isUpdating, + canChangeVersions, }) => { const { t } = useTranslation("workspacePage") const { canCancel, canAcceptJobs, actions } = buttonAbilities(workspaceStatus) @@ -50,6 +55,11 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.settings]: ( ), + [ButtonTypesEnum.changeVersion]: canChangeVersions ? ( + + ) : ( + <> + ), [ButtonTypesEnum.start]: , [ButtonTypesEnum.starting]: ( diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index 383a708f73011..ada352871f2c3 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -12,6 +12,7 @@ export enum ButtonTypesEnum { update = "update", updating = "updating", settings = "settings", + changeVersion = "changeVersion", // disabled buttons canceling = "canceling", deleted = "deleted", @@ -44,6 +45,7 @@ const statusToAbilities: Record = { actions: [ ButtonTypesEnum.stop, ButtonTypesEnum.settings, + ButtonTypesEnum.changeVersion, ButtonTypesEnum.delete, ], canCancel: false, @@ -58,6 +60,7 @@ const statusToAbilities: Record = { actions: [ ButtonTypesEnum.start, ButtonTypesEnum.settings, + ButtonTypesEnum.changeVersion, ButtonTypesEnum.delete, ], canCancel: false, @@ -68,6 +71,7 @@ const statusToAbilities: Record = { ButtonTypesEnum.start, ButtonTypesEnum.stop, ButtonTypesEnum.settings, + ButtonTypesEnum.changeVersion, ButtonTypesEnum.delete, ], canCancel: false, @@ -79,6 +83,7 @@ const statusToAbilities: Record = { ButtonTypesEnum.start, ButtonTypesEnum.stop, ButtonTypesEnum.settings, + ButtonTypesEnum.changeVersion, ButtonTypesEnum.delete, ], canCancel: false, diff --git a/site/src/pages/WorkspacePage/ChangeVersionDialog.tsx b/site/src/pages/WorkspacePage/ChangeVersionDialog.tsx new file mode 100644 index 0000000000000..7dd8a7a2cfbec --- /dev/null +++ b/site/src/pages/WorkspacePage/ChangeVersionDialog.tsx @@ -0,0 +1,125 @@ +import { DialogProps } from "components/Dialogs/Dialog" +import { FC, useRef, useState } from "react" +import { FormFields } from "components/Form/Form" +import TextField from "@material-ui/core/TextField" +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import { Stack } from "components/Stack/Stack" +import { Template, TemplateVersion } from "api/typesGenerated" +import { Loader } from "components/Loader/Loader" +import Autocomplete from "@material-ui/lab/Autocomplete" +import { createDayString } from "util/createDayString" +import { AvatarData } from "components/AvatarData/AvatarData" +import { Pill } from "components/Pill/Pill" +import { Avatar } from "components/Avatar/Avatar" +import CircularProgress from "@material-ui/core/CircularProgress" + +export type ChangeVersionDialogProps = DialogProps & { + template: Template | undefined + templateVersions: TemplateVersion[] | undefined + defaultTemplateVersion: TemplateVersion | undefined + onClose: () => void + onConfirm: (templateVersion: TemplateVersion) => void +} + +export const ChangeVersionDialog: FC = ({ + onConfirm, + onClose, + template, + templateVersions, + defaultTemplateVersion, + ...dialogProps +}) => { + const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false) + const selectedTemplateVersion = useRef() + + return ( + { + if (selectedTemplateVersion.current) { + onConfirm(selectedTemplateVersion.current) + } + }} + hideCancel={false} + type="success" + cancelText="Cancel" + confirmText="Change" + title="Change version" + description={ + +

You are about to change the version of this workspace.

+ {templateVersions ? ( + + { + selectedTemplateVersion.current = + newTemplateVersion ?? undefined + }} + onOpen={() => { + setIsAutocompleteOpen(true) + }} + onClose={() => { + setIsAutocompleteOpen(false) + }} + getOptionSelected={( + option: TemplateVersion, + value: TemplateVersion, + ) => option.id === value.id} + getOptionLabel={(option) => option.name} + renderOption={(option: TemplateVersion) => ( + + {option.name} + + } + title={ + + {option.name} + {template?.active_version_id === option.id && ( + + )} + + } + subtitle={createDayString(option.created_at)} + /> + )} + renderInput={(params) => ( + + {!templateVersions ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + + ) : ( + + )} +
+ } + /> + ) +} diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 98077e4a0548f..c688735974787 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -3,7 +3,7 @@ import { ProvisionerJobLog } from "api/typesGenerated" import { useDashboard } from "components/Dashboard/DashboardProvider" import dayjs from "dayjs" import { useFeatureVisibility } from "hooks/useFeatureVisibility" -import { useEffect } from "react" +import { useEffect, useState } from "react" import { Helmet } from "react-helmet-async" import { useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" @@ -27,6 +27,9 @@ import { workspaceMachine, } from "../../xServices/workspace/workspaceXService" import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog" +import { ChangeVersionDialog } from "./ChangeVersionDialog" +import { useQuery } from "@tanstack/react-query" +import { getTemplateVersions } from "api/api" interface WorkspaceReadyPageProps { workspaceState: StateFrom @@ -67,6 +70,13 @@ export const WorkspaceReadyPage = ({ const { t } = useTranslation("workspacePage") const favicon = getFaviconByStatus(workspace.latest_build) const navigate = useNavigate() + const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false) + const { data: templateVersions } = useQuery({ + queryKey: ["template", "versions", workspace.template_id], + queryFn: () => getTemplateVersions(workspace.template_id), + enabled: changeVersionDialogOpen, + }) + const dashboard = useDashboard() // keep banner machine in sync with workspace useEffect(() => { @@ -119,10 +129,16 @@ export const WorkspaceReadyPage = ({ handleCancel={() => workspaceSend({ type: "CANCEL" })} handleSettings={() => navigate("settings")} handleBuildRetry={() => workspaceSend({ type: "RETRY_BUILD" })} + handleChangeVersion={() => { + setChangeVersionDialogOpen(true) + }} resources={workspace.latest_build.resources} builds={builds} canUpdateWorkspace={canUpdateWorkspace} canUpdateTemplate={canUpdateTemplate} + canChangeVersions={ + canUpdateTemplate && dashboard.experiments.includes("template_editor") + } hideSSHButton={featureVisibility["browser_only"]} hideVSCodeDesktopButton={featureVisibility["browser_only"]} workspaceErrors={{ @@ -160,6 +176,24 @@ export const WorkspaceReadyPage = ({ workspaceSend({ type: "UPDATE", buildParameters }) }} /> + workspace.latest_build.template_version_id === v.id, + )} + open={changeVersionDialogOpen} + onClose={() => { + setChangeVersionDialogOpen(false) + }} + onConfirm={(templateVersion) => { + setChangeVersionDialogOpen(false) + workspaceSend({ + type: "CHANGE_VERSION", + templateVersionId: templateVersion.id, + }) + }} + /> ) } diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 1c888e2d2576a..2188f33d70949 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -80,6 +80,8 @@ export interface WorkspaceContext { createBuildLogLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"] // SSH Config sshPrefix?: string + // Change version + templateVersionIdToChange?: TypesGen.TemplateVersion["id"] } export type WorkspaceEvent = @@ -90,6 +92,11 @@ export type WorkspaceEvent = | { type: "DELETE" } | { type: "CANCEL_DELETE" } | { type: "UPDATE"; buildParameters?: TypesGen.WorkspaceBuildParameter[] } + | { + type: "CHANGE_VERSION" + templateVersionId: TypesGen.TemplateVersion["id"] + buildParameters?: TypesGen.WorkspaceBuildParameter[] + } | { type: "CANCEL" } | { type: "REFRESH_TIMELINE" @@ -157,6 +164,9 @@ export const workspaceMachine = createMachine( updateWorkspace: { data: TypesGen.WorkspaceBuild } + changeWorkspaceVersion: { + data: TypesGen.WorkspaceBuild + } startWorkspace: { data: TypesGen.WorkspaceBuild } @@ -290,6 +300,10 @@ export const workspaceMachine = createMachine( STOP: "requestingStop", ASK_DELETE: "askingDelete", UPDATE: "requestingUpdate", + CHANGE_VERSION: { + target: "requestingChangeVersion", + actions: ["assignTemplateVersionIdToChange"], + }, CANCEL: "requestingCancel", RETRY_BUILD: [ { @@ -341,10 +355,37 @@ export const workspaceMachine = createMachine( ], }, }, + requestingChangeVersion: { + entry: ["clearBuildError"], + invoke: { + src: "changeWorkspaceVersion", + onDone: { + target: "idle", + actions: ["assignBuild", "clearTemplateVersionIdToChange"], + }, + onError: [ + { + target: "askingForMissedBuildParameters", + cond: "isMissingBuildParameterError", + actions: ["assignMissedParameters"], + }, + { + target: "idle", + actions: ["assignBuildError"], + }, + ], + }, + }, askingForMissedBuildParameters: { on: { CANCEL: "idle", - UPDATE: "requestingUpdate", + UPDATE: [ + { + target: "requestingChangeVersion", + cond: "isChangingVersion", + }, + { target: "requestingUpdate" }, + ], }, }, requestingStart: { @@ -669,6 +710,14 @@ export const workspaceMachine = createMachine( // Debug mode when build fails enableDebugMode: assign({ createBuildLogLevel: (_) => "debug" as const }), disableDebugMode: assign({ createBuildLogLevel: (_) => undefined }), + // Change version + assignTemplateVersionIdToChange: assign({ + templateVersionIdToChange: (_, { templateVersionId }) => + templateVersionId, + }), + clearTemplateVersionIdToChange: assign({ + templateVersionIdToChange: (_) => undefined, + }), }, guards: { moreBuildsAvailable, @@ -684,6 +733,8 @@ export const workspaceMachine = createMachine( lastBuildWasDeleting: ({ workspace }) => { return workspace?.latest_build.transition === "delete" }, + isChangingVersion: ({ templateVersionIdToChange }) => + Boolean(templateVersionIdToChange), }, services: { getWorkspace: async ({ username, workspaceName }) => { @@ -708,6 +759,23 @@ export const workspaceMachine = createMachine( send({ type: "REFRESH_TIMELINE" }) return build }, + changeWorkspaceVersion: + ({ workspace, templateVersionIdToChange }, { buildParameters }) => + async (send) => { + if (!workspace) { + throw new Error("Workspace is not set") + } + if (!templateVersionIdToChange) { + throw new Error("Template version id to change is not set") + } + const build = await API.changeWorkspaceVersion( + workspace, + templateVersionIdToChange, + buildParameters, + ) + send({ type: "REFRESH_TIMELINE" }) + return build + }, startWorkspace: (context) => async (send) => { if (context.workspace) { const startWorkspacePromise = await API.startWorkspace(