fix(UI): workspace restart button stops build before starting a new one (#7301)

* feat(UI): add workspace restart button (#7137)

* Refactor primary buttons

* refactor(site): Always show the main actions

* Remove tests that are testes on Storybook

* Fix tests

* Fix keys

* added restart btn

---------

Co-authored-by: BrunoQuaresma <bruno_nonato_quaresma@hotmail.com>

* added restart hook

* added error handling

* going back to chaining in success callback

* add restarting btn

* added test

* PR feedback

---------

Co-authored-by: BrunoQuaresma <bruno_nonato_quaresma@hotmail.com>
This commit is contained in:
Kira Pilot
2023-04-28 11:16:04 -07:00
committed by GitHub
co-authored by BrunoQuaresma
parent 3078cd3d98
commit a2ff674158
10 changed files with 133 additions and 13 deletions
+49 -2
View File
@@ -3,6 +3,7 @@ import dayjs from "dayjs"
import * as Types from "./types"
import { DeploymentConfig } from "./types"
import * as TypesGen from "./typesGenerated"
import { delay } from "utils/delay"
// Adds 304 for the default axios validateStatus function
// https://github.com/axios/axios#handling-errors Check status here
@@ -476,6 +477,35 @@ export const getWorkspaceByOwnerAndName = async (
return response.data
}
export function waitForBuild(build: TypesGen.WorkspaceBuild) {
return new Promise<TypesGen.ProvisionerJob | undefined>((res, reject) => {
void (async () => {
let latestJobInfo: TypesGen.ProvisionerJob | undefined = undefined
while (
!["succeeded", "canceled"].some((status) =>
latestJobInfo?.status.includes(status),
)
) {
const { job } = await getWorkspaceBuildByNumber(
build.workspace_owner_name,
build.workspace_name,
String(build.build_number),
)
latestJobInfo = job
if (latestJobInfo.status === "failed") {
return reject(latestJobInfo)
}
await delay(1000)
}
return res(latestJobInfo)
})()
})
}
export const postWorkspaceBuild = async (
workspaceId: string,
data: TypesGen.CreateWorkspaceBuildRequest,
@@ -489,12 +519,12 @@ export const postWorkspaceBuild = async (
export const startWorkspace = (
workspaceId: string,
templateVersionID: string,
templateVersionId: string,
logLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"],
) =>
postWorkspaceBuild(workspaceId, {
transition: "start",
template_version_id: templateVersionID,
template_version_id: templateVersionId,
log_level: logLevel,
})
export const stopWorkspace = (
@@ -505,6 +535,7 @@ export const stopWorkspace = (
transition: "stop",
log_level: logLevel,
})
export const deleteWorkspace = (
workspaceId: string,
logLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"],
@@ -523,6 +554,22 @@ export const cancelWorkspaceBuild = async (
return response.data
}
export const restartWorkspace = async (workspace: TypesGen.Workspace) => {
const stopBuild = await stopWorkspace(workspace.id)
const awaitedStopBuild = await waitForBuild(stopBuild)
// If the restart is canceled halfway through, make sure we bail
if (awaitedStopBuild?.status === "canceled") {
return
}
const startBuild = await startWorkspace(
workspace.id,
workspace.latest_build.template_version_id,
)
await waitForBuild(startBuild)
}
export const cancelTemplateVersionBuild = async (
templateVersionId: TypesGen.TemplateVersion["id"],
): Promise<Types.Message> => {
@@ -41,12 +41,14 @@ export interface WorkspaceProps {
}
handleStart: () => void
handleStop: () => void
handleRestart: () => void
handleDelete: () => void
handleUpdate: () => void
handleCancel: () => void
handleSettings: () => void
handleChangeVersion: () => void
isUpdating: boolean
isRestarting: boolean
workspace: TypesGen.Workspace
resources?: TypesGen.WorkspaceResource[]
builds?: TypesGen.WorkspaceBuild[]
@@ -72,6 +74,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
scheduleProps,
handleStart,
handleStop,
handleRestart,
handleDelete,
handleUpdate,
handleCancel,
@@ -79,6 +82,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleChangeVersion,
workspace,
isUpdating,
isRestarting,
resources,
builds,
canUpdateWorkspace,
@@ -132,6 +136,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
isOutdated={workspace.outdated}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
@@ -139,6 +144,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleChangeVersion={handleChangeVersion}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
/>
</Stack>
}
@@ -3,8 +3,9 @@ import BlockIcon from "@material-ui/icons/Block"
import CloudQueueIcon from "@material-ui/icons/CloudQueue"
import CropSquareIcon from "@material-ui/icons/CropSquare"
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline"
import ReplayIcon from "@material-ui/icons/Replay"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { FC } from "react"
import { FC, PropsWithChildren } from "react"
import { useTranslation } from "react-i18next"
import { makeStyles } from "@material-ui/core/styles"
@@ -12,7 +13,7 @@ interface WorkspaceAction {
handleAction: () => void
}
export const UpdateButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
export const UpdateButton: FC<PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const { t } = useTranslation("workspacePage")
@@ -30,7 +31,7 @@ export const UpdateButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
)
}
export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
export const StartButton: FC<PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const { t } = useTranslation("workspacePage")
@@ -48,7 +49,7 @@ export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
)
}
export const StopButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
export const StopButton: FC<PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const { t } = useTranslation("workspacePage")
@@ -66,7 +67,25 @@ export const StopButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
)
}
export const CancelButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
export const RestartButton: FC<PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const { t } = useTranslation("workspacePage")
const styles = useStyles()
return (
<Button
variant="outlined"
startIcon={<ReplayIcon />}
onClick={handleAction}
className={styles.fixedWidth}
>
{t("actionButton.restart")}
</Button>
)
}
export const CancelButton: FC<PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
return (
@@ -80,7 +99,7 @@ interface DisabledProps {
label: string
}
export const DisabledButton: FC<React.PropsWithChildren<DisabledProps>> = ({
export const DisabledButton: FC<PropsWithChildren<DisabledProps>> = ({
label,
}) => {
return (
@@ -94,7 +113,7 @@ interface LoadingProps {
label: string
}
export const ActionLoadingButton: FC<React.PropsWithChildren<LoadingProps>> = ({
export const ActionLoadingButton: FC<PropsWithChildren<LoadingProps>> = ({
label,
}) => {
const styles = useStyles()
@@ -15,6 +15,7 @@ const Template: Story<WorkspaceActionsProps> = (args) => (
const defaultArgs = {
handleStart: action("start"),
handleStop: action("stop"),
handleRestart: action("restart"),
handleDelete: action("delete"),
handleUpdate: action("update"),
handleCancel: action("cancel"),
@@ -5,13 +5,14 @@ import { makeStyles } from "@material-ui/core/styles"
import MoreVertOutlined from "@material-ui/icons/MoreVertOutlined"
import { FC, ReactNode, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { WorkspaceStatus } from "../../api/typesGenerated"
import { WorkspaceStatus } from "api/typesGenerated"
import {
ActionLoadingButton,
CancelButton,
DisabledButton,
StartButton,
StopButton,
RestartButton,
UpdateButton,
} from "./Buttons"
import {
@@ -28,12 +29,14 @@ export interface WorkspaceActionsProps {
isOutdated: boolean
handleStart: () => void
handleStop: () => void
handleRestart: () => void
handleDelete: () => void
handleUpdate: () => void
handleCancel: () => void
handleSettings: () => void
handleChangeVersion: () => void
isUpdating: boolean
isRestarting: boolean
children?: ReactNode
canChangeVersions: boolean
}
@@ -43,12 +46,14 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
isOutdated,
handleStart,
handleStop,
handleRestart,
handleDelete,
handleUpdate,
handleCancel,
handleSettings,
handleChangeVersion,
isUpdating,
isRestarting,
canChangeVersions,
}) => {
const styles = useStyles()
@@ -91,6 +96,13 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
key={ButtonTypesEnum.stopping}
/>
),
[ButtonTypesEnum.restart]: <RestartButton handleAction={handleRestart} />,
[ButtonTypesEnum.restarting]: (
<ActionLoadingButton
label="Restarting"
key={ButtonTypesEnum.restarting}
/>
),
[ButtonTypesEnum.deleting]: (
<ActionLoadingButton
label={t("actionButton.deleting")}
@@ -129,7 +141,11 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
(isUpdating
? buttonMapping[ButtonTypesEnum.updating]
: buttonMapping[ButtonTypesEnum.update])}
{actionsByStatus.map((action) => buttonMapping[action])}
{isRestarting && buttonMapping[ButtonTypesEnum.restarting]}
{!isRestarting &&
actionsByStatus.map((action) => (
<span key={action}>{buttonMapping[action]}</span>
))}
{canCancel && <CancelButton handleAction={handleCancel} />}
<div>
<Button
@@ -7,6 +7,8 @@ export enum ButtonTypesEnum {
starting = "starting",
stop = "stop",
stopping = "stopping",
restart = "restart",
restarting = "restarting",
deleting = "deleting",
update = "update",
updating = "updating",
@@ -39,7 +41,7 @@ const statusToActions: Record<WorkspaceStatus, WorkspaceAbilities> = {
canAcceptJobs: false,
},
running: {
actions: [ButtonTypesEnum.stop],
actions: [ButtonTypesEnum.stop, ButtonTypesEnum.restart],
canCancel: false,
canAcceptJobs: true,
},
+1
View File
@@ -21,6 +21,7 @@
"actionButton": {
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"delete": "Delete",
"cancel": "Cancel",
"update": "Update",
@@ -151,6 +151,17 @@ describe("WorkspacePage", () => {
)
})
it("requests a stop when the user presses Restart", async () => {
const stopWorkspaceMock = jest
.spyOn(api, "stopWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild)
await testButton("Restart", stopWorkspaceMock)
const button = await screen.findByText("Restarting")
expect(button).toBeInTheDocument()
})
it("requests cancellation when the user presses Cancel", async () => {
server.use(
rest.get(
@@ -30,6 +30,7 @@ import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"
import { ChangeVersionDialog } from "./ChangeVersionDialog"
import { useQuery } from "@tanstack/react-query"
import { getTemplateVersions } from "api/api"
import { useRestartWorkspace } from "./hooks"
interface WorkspaceReadyPageProps {
workspaceState: StateFrom<typeof workspaceMachine>
@@ -77,6 +78,12 @@ export const WorkspaceReadyPage = ({
enabled: changeVersionDialogOpen,
})
const {
mutate: restartWorkspace,
error: restartBuildError,
isLoading: isRestarting,
} = useRestartWorkspace()
// keep banner machine in sync with workspace
useEffect(() => {
bannerSend({ type: "REFRESH_WORKSPACE", workspace })
@@ -120,9 +127,11 @@ export const WorkspaceReadyPage = ({
),
}}
isUpdating={workspaceState.matches("ready.build.requestingUpdate")}
isRestarting={isRestarting}
workspace={workspace}
handleStart={() => workspaceSend({ type: "START" })}
handleStop={() => workspaceSend({ type: "STOP" })}
handleRestart={() => restartWorkspace(workspace)}
handleDelete={() => workspaceSend({ type: "ASK_DELETE" })}
handleUpdate={() => workspaceSend({ type: "UPDATE" })}
handleCancel={() => workspaceSend({ type: "CANCEL" })}
@@ -140,7 +149,7 @@ export const WorkspaceReadyPage = ({
hideVSCodeDesktopButton={featureVisibility["browser_only"]}
workspaceErrors={{
[WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError,
[WorkspaceErrors.BUILD_ERROR]: buildError,
[WorkspaceErrors.BUILD_ERROR]: buildError || restartBuildError,
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
}}
buildInfo={buildInfo}
+8
View File
@@ -0,0 +1,8 @@
import { restartWorkspace } from "api/api"
import { useMutation } from "@tanstack/react-query"
export const useRestartWorkspace = () => {
return useMutation({
mutationFn: restartWorkspace,
})
}