feat(site): Add change version for template admins (#6988)

This commit is contained in:
Bruno Quaresma
2023-04-04 10:25:49 -03:00
committed by GitHub
parent c12bc39821
commit 29e9b9e663
10 changed files with 311 additions and 6 deletions
+37 -2
View File
@@ -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"
@@ -65,7 +65,7 @@ if (token !== null && token.getAttribute("content") !== null) {
}
}
const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
const CONTENT_TYPE_JSON = {
"Content-Type": "application/json",
}
@@ -975,6 +975,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<TypesGen.WorkspaceBuild> => {
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
@@ -31,7 +31,7 @@ export const AvatarData: FC<PropsWithChildren<AvatarDataProps>> = ({
>
{avatar}
<Stack spacing={0}>
<Stack spacing={0} className={styles.info}>
<span className={styles.title}>{title}</span>
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
</Stack>
@@ -42,6 +42,11 @@ export const AvatarData: FC<PropsWithChildren<AvatarDataProps>> = ({
const useStyles = makeStyles((theme) => ({
root: {
minHeight: theme.spacing(5), // Make it predictable for the skeleton
width: "100%",
},
info: {
width: "100%",
},
title: {
@@ -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<React.PropsWithChildren<WorkspaceAction>> = ({
)
}
export const ChangeVersionButton: FC<
React.PropsWithChildren<WorkspaceAction>
> = ({ handleAction }) => {
const styles = useStyles()
return (
<Button
variant="outlined"
className={styles.actionButton}
startIcon={<HistoryOutlined />}
onClick={handleAction}
>
Change version
</Button>
)
}
export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
@@ -26,7 +26,6 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
}) => {
const styles = useStyles()
const { t } = useTranslation("common")
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
const [searchState, sendSearch] = useMachine(searchUserMachine)
const { searchResults } = searchState.context
@@ -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<Record<WorkspaceErrors, Error | unknown>>
@@ -76,12 +78,14 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleUpdate,
handleCancel,
handleSettings,
handleChangeVersion,
workspace,
isUpdating,
resources,
builds,
canUpdateWorkspace,
canUpdateTemplate,
canChangeVersions,
workspaceErrors,
hideSSHButton,
hideVSCodeDesktopButton,
@@ -142,6 +146,8 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleSettings={handleSettings}
handleChangeVersion={handleChangeVersion}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
/>
</Stack>
@@ -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<WorkspaceActionsProps> = ({
@@ -35,7 +38,9 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleUpdate,
handleCancel,
handleSettings,
handleChangeVersion,
isUpdating,
canChangeVersions,
}) => {
const { t } = useTranslation("workspacePage")
const { canCancel, canAcceptJobs, actions } = buttonAbilities(workspaceStatus)
@@ -50,6 +55,11 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
[ButtonTypesEnum.settings]: (
<SettingsButton handleAction={handleSettings} />
),
[ButtonTypesEnum.changeVersion]: canChangeVersions ? (
<ChangeVersionButton handleAction={handleChangeVersion} />
) : (
<></>
),
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
[ButtonTypesEnum.starting]: (
<ActionLoadingButton label={t("actionButton.starting")} />
@@ -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<WorkspaceStatus, WorkspaceAbilities> = {
actions: [
ButtonTypesEnum.stop,
ButtonTypesEnum.settings,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
canCancel: false,
@@ -58,6 +60,7 @@ const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.settings,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
canCancel: false,
@@ -68,6 +71,7 @@ const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
ButtonTypesEnum.start,
ButtonTypesEnum.stop,
ButtonTypesEnum.settings,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
canCancel: false,
@@ -79,6 +83,7 @@ const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
ButtonTypesEnum.start,
ButtonTypesEnum.stop,
ButtonTypesEnum.settings,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
canCancel: false,
@@ -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<ChangeVersionDialogProps> = ({
onConfirm,
onClose,
template,
templateVersions,
defaultTemplateVersion,
...dialogProps
}) => {
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
const selectedTemplateVersion = useRef<TemplateVersion | undefined>()
return (
<ConfirmDialog
{...dialogProps}
onClose={onClose}
onConfirm={() => {
if (selectedTemplateVersion.current) {
onConfirm(selectedTemplateVersion.current)
}
}}
hideCancel={false}
type="success"
cancelText="Cancel"
confirmText="Change"
title="Change version"
description={
<Stack>
<p>You are about to change the version of this workspace.</p>
{templateVersions ? (
<FormFields>
<Autocomplete
disableClearable
options={templateVersions}
defaultValue={defaultTemplateVersion}
id="template-version-autocomplete"
open={isAutocompleteOpen}
onChange={(_, newTemplateVersion) => {
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) => (
<AvatarData
avatar={
<Avatar src={option.created_by.avatar_url}>
{option.name}
</Avatar>
}
title={
<Stack
direction="row"
justifyContent="space-between"
style={{ width: "100%" }}
>
{option.name}
{template?.active_version_id === option.id && (
<Pill text="Active" type="success" />
)}
</Stack>
}
subtitle={createDayString(option.created_at)}
/>
)}
renderInput={(params) => (
<TextField
{...params}
fullWidth
variant="outlined"
placeholder="Template version name"
InputProps={{
...params.InputProps,
endAdornment: (
<>
{!templateVersions ? (
<CircularProgress size={16} />
) : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>
</FormFields>
) : (
<Loader />
)}
</Stack>
}
/>
)
}
@@ -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<typeof workspaceMachine>
@@ -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 })
}}
/>
<ChangeVersionDialog
templateVersions={templateVersions?.reverse()}
template={template}
defaultTemplateVersion={templateVersions?.find(
(v) => workspace.latest_build.template_version_id === v.id,
)}
open={changeVersionDialogOpen}
onClose={() => {
setChangeVersionDialogOpen(false)
}}
onConfirm={(templateVersion) => {
setChangeVersionDialogOpen(false)
workspaceSend({
type: "CHANGE_VERSION",
templateVersionId: templateVersion.id,
})
}}
/>
</>
)
}
@@ -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(