mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: UI for canceling workspace builds (#1735)
* Start hooking up cancel * Update xservice * Render cancel Changes behavior of other buttons too * Make outdated workspace story show max buttons * Remove retry code * Remove loading button state * Fix type, extend tests * Update story
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosRequestHeaders } from "axios"
|
||||
import * as Types from "./types"
|
||||
import { WorkspaceBuildTransition } from "./types"
|
||||
import * as TypesGen from "./typesGenerated"
|
||||
|
||||
@@ -161,6 +162,11 @@ export const startWorkspace = postWorkspaceBuild("start")
|
||||
export const stopWorkspace = postWorkspaceBuild("stop")
|
||||
export const deleteWorkspace = postWorkspaceBuild("delete")
|
||||
|
||||
export const cancelWorkspaceBuild = async (workspaceBuildId: TypesGen.WorkspaceBuild["id"]): Promise<Types.Message> => {
|
||||
const response = await axios.patch(`/api/v2/workspacebuilds/${workspaceBuildId}/cancel`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createUser = async (user: TypesGen.CreateUserRequest): Promise<TypesGen.User> => {
|
||||
const response = await axios.post<TypesGen.User>("/api/v2/users", user)
|
||||
return response.data
|
||||
|
||||
@@ -12,3 +12,5 @@ export interface ReconnectingPTYRequest {
|
||||
}
|
||||
|
||||
export type WorkspaceBuildTransition = "start" | "stop" | "delete"
|
||||
|
||||
export type Message = { message: string }
|
||||
|
||||
@@ -31,7 +31,6 @@ Started.args = {
|
||||
workspace: MockWorkspace,
|
||||
handleStart: action("start"),
|
||||
handleStop: action("stop"),
|
||||
handleRetry: action("retry"),
|
||||
resources: [MockWorkspaceResource, MockWorkspaceResource2],
|
||||
builds: [MockWorkspaceBuild],
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
|
||||
export interface WorkspaceProps {
|
||||
handleStart: () => void
|
||||
handleStop: () => void
|
||||
handleRetry: () => void
|
||||
handleUpdate: () => void
|
||||
handleCancel: () => void
|
||||
workspace: TypesGen.Workspace
|
||||
resources?: TypesGen.WorkspaceResource[]
|
||||
getResourcesError?: Error
|
||||
@@ -28,8 +28,8 @@ export interface WorkspaceProps {
|
||||
export const Workspace: React.FC<WorkspaceProps> = ({
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleRetry,
|
||||
handleUpdate,
|
||||
handleCancel,
|
||||
workspace,
|
||||
resources,
|
||||
getResourcesError,
|
||||
@@ -55,8 +55,8 @@ export const Workspace: React.FC<WorkspaceProps> = ({
|
||||
workspace={workspace}
|
||||
handleStart={handleStart}
|
||||
handleStop={handleStop}
|
||||
handleRetry={handleRetry}
|
||||
handleUpdate={handleUpdate}
|
||||
handleCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,14 +14,4 @@ export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
icon: <PlayArrowRoundedIcon />,
|
||||
label: "Start workspace",
|
||||
loadingLabel: "Starting workspace",
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
export const Loading = Template.bind({})
|
||||
Loading.args = {
|
||||
icon: <PlayArrowRoundedIcon />,
|
||||
label: "Start workspace",
|
||||
loadingLabel: "Starting workspace",
|
||||
isLoading: true,
|
||||
}
|
||||
|
||||
@@ -1,42 +1,17 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import CircularProgress from "@material-ui/core/CircularProgress"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import React from "react"
|
||||
|
||||
export interface WorkspaceActionButtonProps {
|
||||
label: string
|
||||
loadingLabel: string
|
||||
isLoading: boolean
|
||||
icon: JSX.Element
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const WorkspaceActionButton: React.FC<WorkspaceActionButtonProps> = ({
|
||||
label,
|
||||
loadingLabel,
|
||||
isLoading,
|
||||
icon,
|
||||
onClick,
|
||||
className,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
||||
export const WorkspaceActionButton: React.FC<WorkspaceActionButtonProps> = ({ label, icon, onClick, className }) => {
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
startIcon={isLoading ? <CircularProgress size={12} className={styles.spinner} /> : icon}
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? loadingLabel : label}
|
||||
<Button className={className} startIcon={icon} onClick={onClick}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
spinner: {
|
||||
color: theme.palette.text.disabled,
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import CancelIcon from "@material-ui/icons/Cancel"
|
||||
import CloudDownloadIcon from "@material-ui/icons/CloudDownload"
|
||||
import PlayArrowRoundedIcon from "@material-ui/icons/PlayArrowRounded"
|
||||
import ReplayIcon from "@material-ui/icons/Replay"
|
||||
import StopIcon from "@material-ui/icons/Stop"
|
||||
import React from "react"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
@@ -17,7 +17,7 @@ export const Language = {
|
||||
stopping: "Stopping workspace",
|
||||
start: "Start workspace",
|
||||
starting: "Starting workspace",
|
||||
retry: "Retry",
|
||||
cancel: "Cancel action",
|
||||
update: "Update workspace",
|
||||
}
|
||||
|
||||
@@ -28,20 +28,32 @@ export const Language = {
|
||||
const canAcceptJobs = (workspaceStatus: WorkspaceStatus) =>
|
||||
["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus)
|
||||
|
||||
/**
|
||||
* Jobs that are in progress (queued or pending) can be canceled.
|
||||
* @param workspaceStatus WorkspaceStatus
|
||||
* @returns boolean
|
||||
*/
|
||||
const canCancelJobs = (workspaceStatus: WorkspaceStatus) =>
|
||||
["starting", "stopping", "deleting"].includes(workspaceStatus)
|
||||
|
||||
const canStart = (workspaceStatus: WorkspaceStatus) => ["stopped", "canceled", "error"].includes(workspaceStatus)
|
||||
|
||||
const canStop = (workspaceStatus: WorkspaceStatus) => ["started", "canceled", "error"].includes(workspaceStatus)
|
||||
|
||||
export interface WorkspaceActionsProps {
|
||||
workspace: Workspace
|
||||
handleStart: () => void
|
||||
handleStop: () => void
|
||||
handleRetry: () => void
|
||||
handleUpdate: () => void
|
||||
handleCancel: () => void
|
||||
}
|
||||
|
||||
export const WorkspaceActions: React.FC<WorkspaceActionsProps> = ({
|
||||
workspace,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleRetry,
|
||||
handleUpdate,
|
||||
handleCancel,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const workspaceStatus = getWorkspaceStatus(workspace.latest_build)
|
||||
@@ -51,30 +63,29 @@ export const WorkspaceActions: React.FC<WorkspaceActionsProps> = ({
|
||||
<Link underline="none" component={RouterLink} to="edit">
|
||||
<Button variant="outlined">Settings</Button>
|
||||
</Link>
|
||||
{(workspaceStatus === "started" || workspaceStatus === "stopping") && (
|
||||
<WorkspaceActionButton
|
||||
className={styles.actionButton}
|
||||
icon={<StopIcon />}
|
||||
onClick={handleStop}
|
||||
label={Language.stop}
|
||||
loadingLabel={Language.stopping}
|
||||
isLoading={workspaceStatus === "stopping"}
|
||||
/>
|
||||
)}
|
||||
{(workspaceStatus === "stopped" || workspaceStatus === "starting") && (
|
||||
{canStart(workspaceStatus) && (
|
||||
<WorkspaceActionButton
|
||||
className={styles.actionButton}
|
||||
icon={<PlayArrowRoundedIcon />}
|
||||
onClick={handleStart}
|
||||
label={Language.start}
|
||||
loadingLabel={Language.starting}
|
||||
isLoading={workspaceStatus === "starting"}
|
||||
/>
|
||||
)}
|
||||
{workspaceStatus === "error" && (
|
||||
<Button className={styles.actionButton} startIcon={<ReplayIcon />} onClick={handleRetry}>
|
||||
{Language.retry}
|
||||
</Button>
|
||||
{canStop(workspaceStatus) && (
|
||||
<WorkspaceActionButton
|
||||
className={styles.actionButton}
|
||||
icon={<StopIcon />}
|
||||
onClick={handleStop}
|
||||
label={Language.stop}
|
||||
/>
|
||||
)}
|
||||
{canCancelJobs(workspaceStatus) && (
|
||||
<WorkspaceActionButton
|
||||
className={styles.actionButton}
|
||||
icon={<CancelIcon />}
|
||||
onClick={handleCancel}
|
||||
label={Language.cancel}
|
||||
/>
|
||||
)}
|
||||
{workspace.outdated && canAcceptJobs(workspaceStatus) && (
|
||||
<Button className={styles.actionButton} startIcon={<CloudDownloadIcon />} onClick={handleUpdate}>
|
||||
@@ -89,6 +100,6 @@ const useStyles = makeStyles((theme) => ({
|
||||
actionButton: {
|
||||
// Set fixed width for the action buttons so they will not change the size
|
||||
// during the transitions
|
||||
width: theme.spacing(30),
|
||||
width: theme.spacing(27),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Workspace } from "../../api/typesGenerated"
|
||||
import { Language } from "../../components/WorkspaceActions/WorkspaceActions"
|
||||
import {
|
||||
MockBuilds,
|
||||
MockCanceledWorkspace,
|
||||
MockCancelingWorkspace,
|
||||
MockDeletedWorkspace,
|
||||
MockDeletingWorkspace,
|
||||
@@ -86,45 +87,16 @@ describe("Workspace Page", () => {
|
||||
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
|
||||
await testButton(Language.start, startWorkspaceMock)
|
||||
})
|
||||
it("requests a start job when the user presses Retry after trying to start", async () => {
|
||||
// Use a workspace that failed during start
|
||||
it("requests cancellation when the user presses Cancel", async () => {
|
||||
server.use(
|
||||
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...MockFailedWorkspace,
|
||||
latest_build: {
|
||||
...MockFailedWorkspace.latest_build,
|
||||
transition: "start",
|
||||
},
|
||||
}),
|
||||
)
|
||||
return res(ctx.status(200), ctx.json(MockStartingWorkspace))
|
||||
}),
|
||||
)
|
||||
const startWorkSpaceMock = jest.spyOn(api, "startWorkspace").mockResolvedValueOnce(MockWorkspaceBuild)
|
||||
await testButton(Language.retry, startWorkSpaceMock)
|
||||
})
|
||||
it("requests a stop job when the user presses Retry after trying to stop", async () => {
|
||||
// Use a workspace that failed during stop
|
||||
server.use(
|
||||
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...MockFailedWorkspace,
|
||||
latest_build: {
|
||||
...MockFailedWorkspace.latest_build,
|
||||
transition: "stop",
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const stopWorkspaceMock = jest
|
||||
.spyOn(api, "stopWorkspace")
|
||||
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
|
||||
await testButton(Language.retry, stopWorkspaceMock)
|
||||
const cancelWorkspaceMock = jest
|
||||
.spyOn(api, "cancelWorkspaceBuild")
|
||||
.mockImplementation(() => Promise.resolve({ message: "job canceled" }))
|
||||
await testButton(Language.cancel, cancelWorkspaceMock)
|
||||
})
|
||||
it("requests a template when the user presses Update", async () => {
|
||||
const getTemplateMock = jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
|
||||
@@ -153,6 +125,9 @@ describe("Workspace Page", () => {
|
||||
it("shows the Canceling status when the workspace is canceling", async () => {
|
||||
await testStatus(MockCancelingWorkspace, DisplayStatusLanguage.canceling)
|
||||
})
|
||||
it("shows the Canceled status when the workspace is canceling", async () => {
|
||||
await testStatus(MockCanceledWorkspace, DisplayStatusLanguage.canceled)
|
||||
})
|
||||
it("shows the Deleting status when the workspace is deleting", async () => {
|
||||
await testStatus(MockDeletingWorkspace, DisplayStatusLanguage.deleting)
|
||||
})
|
||||
|
||||
@@ -36,8 +36,8 @@ export const WorkspacePage: React.FC = () => {
|
||||
workspace={workspace}
|
||||
handleStart={() => workspaceSend("START")}
|
||||
handleStop={() => workspaceSend("STOP")}
|
||||
handleRetry={() => workspaceSend("RETRY")}
|
||||
handleUpdate={() => workspaceSend("UPDATE")}
|
||||
handleCancel={() => workspaceSend("CANCEL")}
|
||||
resources={resources}
|
||||
getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined}
|
||||
builds={builds}
|
||||
|
||||
@@ -182,7 +182,7 @@ export const MockDeletingWorkspace: TypesGen.Workspace = {
|
||||
}
|
||||
export const MockDeletedWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: MockWorkspaceBuildDelete }
|
||||
|
||||
export const MockOutdatedWorkspace: TypesGen.Workspace = { ...MockWorkspace, outdated: true }
|
||||
export const MockOutdatedWorkspace: TypesGen.Workspace = { ...MockFailedWorkspace, outdated: true }
|
||||
|
||||
export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
|
||||
architecture: "amd64",
|
||||
@@ -506,3 +506,7 @@ export const MockWorkspaceBuildLogs: TypesGen.ProvisionerJobLog[] = [
|
||||
output: "",
|
||||
},
|
||||
]
|
||||
|
||||
export const MockCancellationMessage = {
|
||||
message: "Job successfully canceled",
|
||||
}
|
||||
|
||||
@@ -130,4 +130,7 @@ export const handlers = [
|
||||
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/logs", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuildLogs))
|
||||
}),
|
||||
rest.patch("/api/v2/workspacebuilds/:workspaceBuildId/cancel", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockCancellationMessage))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -58,8 +58,8 @@ export const DisplayStatusLanguage = {
|
||||
stopped: "Stopped",
|
||||
deleting: "Deleting",
|
||||
deleted: "Deleted",
|
||||
canceling: "Canceling",
|
||||
canceled: "Canceled",
|
||||
canceling: "Canceling action",
|
||||
canceled: "Canceled action",
|
||||
failed: "Failed",
|
||||
queued: "Queued",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { assign, createMachine, send } from "xstate"
|
||||
import { pure } from "xstate/lib/actions"
|
||||
import * as API from "../../api/api"
|
||||
import * as Types from "../../api/types"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { displayError } from "../../components/GlobalSnackbar/utils"
|
||||
|
||||
@@ -32,14 +33,15 @@ export interface WorkspaceContext {
|
||||
builds?: TypesGen.WorkspaceBuild[]
|
||||
getBuildsError?: Error | unknown
|
||||
loadMoreBuildsError?: Error | unknown
|
||||
cancellationMessage: string
|
||||
}
|
||||
|
||||
export type WorkspaceEvent =
|
||||
| { type: "GET_WORKSPACE"; workspaceId: string }
|
||||
| { type: "START" }
|
||||
| { type: "STOP" }
|
||||
| { type: "RETRY" }
|
||||
| { type: "UPDATE" }
|
||||
| { type: "CANCEL" }
|
||||
| { type: "LOAD_MORE_BUILDS" }
|
||||
| { type: "REFRESH_TIMELINE" }
|
||||
|
||||
@@ -62,6 +64,9 @@ export const workspaceMachine = createMachine(
|
||||
stopWorkspace: {
|
||||
data: TypesGen.WorkspaceBuild
|
||||
}
|
||||
cancelWorkspace: {
|
||||
data: Types.Message
|
||||
}
|
||||
refreshWorkspace: {
|
||||
data: TypesGen.Workspace | undefined
|
||||
}
|
||||
@@ -131,8 +136,8 @@ export const workspaceMachine = createMachine(
|
||||
on: {
|
||||
START: "requestingStart",
|
||||
STOP: "requestingStop",
|
||||
RETRY: [{ cond: "triedToStart", target: "requestingStart" }, { target: "requestingStop" }],
|
||||
UPDATE: "refreshingTemplate",
|
||||
CANCEL: "requestingCancel",
|
||||
},
|
||||
},
|
||||
requestingStart: {
|
||||
@@ -165,6 +170,21 @@ export const workspaceMachine = createMachine(
|
||||
},
|
||||
},
|
||||
},
|
||||
requestingCancel: {
|
||||
entry: "clearCancellationMessage",
|
||||
invoke: {
|
||||
id: "cancelWorkspace",
|
||||
src: "cancelWorkspace",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignCancellationMessage", "refreshTimeline"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["assignCancellationMessage", "displayCancellationError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
refreshingTemplate: {
|
||||
entry: "clearRefreshTemplateError",
|
||||
invoke: {
|
||||
@@ -293,6 +313,17 @@ export const workspaceMachine = createMachine(
|
||||
assign({
|
||||
buildError: undefined,
|
||||
}),
|
||||
assignCancellationMessage: (_, event) =>
|
||||
assign({
|
||||
cancellationMessage: event.data,
|
||||
}),
|
||||
clearCancellationMessage: (_) =>
|
||||
assign({
|
||||
cancellationMessage: undefined,
|
||||
}),
|
||||
displayCancellationError: (context) => {
|
||||
displayError(context.cancellationMessage)
|
||||
},
|
||||
assignRefreshWorkspaceError: (_, event) =>
|
||||
assign({
|
||||
refreshWorkspaceError: event.data,
|
||||
@@ -312,6 +343,7 @@ export const workspaceMachine = createMachine(
|
||||
assign({
|
||||
refreshTemplateError: undefined,
|
||||
}),
|
||||
// Resources
|
||||
assignResources: assign({
|
||||
resources: (_, event) => event.data,
|
||||
}),
|
||||
@@ -369,7 +401,6 @@ export const workspaceMachine = createMachine(
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
triedToStart: (context) => context.workspace?.latest_build.transition === "start",
|
||||
hasMoreBuilds: (_) => false,
|
||||
},
|
||||
services: {
|
||||
@@ -397,6 +428,13 @@ export const workspaceMachine = createMachine(
|
||||
throw Error("Cannot stop workspace without workspace id")
|
||||
}
|
||||
},
|
||||
cancelWorkspace: async (context) => {
|
||||
if (context.workspace) {
|
||||
return await API.cancelWorkspaceBuild(context.workspace.latest_build.id)
|
||||
} else {
|
||||
throw Error("Cannot cancel workspace without build id")
|
||||
}
|
||||
},
|
||||
refreshWorkspace: async (context) => {
|
||||
if (context.workspace) {
|
||||
return await API.getWorkspace(context.workspace.id)
|
||||
|
||||
Reference in New Issue
Block a user