Skip to content

Commit 7802636

Browse files
committed
Add cancel provisioner job
1 parent ffee2ed commit 7802636

File tree

7 files changed

+313
-26
lines changed

7 files changed

+313
-26
lines changed

site/src/api/api.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,17 @@ class ApiMethods {
12471247
return response.data;
12481248
};
12491249

1250+
cancelTemplateVersionDryRun = async (
1251+
templateVersionId: TypesGen.TemplateVersion["id"],
1252+
jobId: string,
1253+
): Promise<TypesGen.Response> => {
1254+
const response = await this.axios.patch(
1255+
`/api/v2/templateversions/${templateVersionId}/dry-run/${jobId}/cancel`,
1256+
);
1257+
1258+
return response.data;
1259+
};
1260+
12501261
createUser = async (
12511262
user: TypesGen.CreateUserRequestWithOrgs,
12521263
): Promise<TypesGen.User> => {
@@ -2302,6 +2313,31 @@ class ApiMethods {
23022313
);
23032314
return res.data;
23042315
};
2316+
2317+
cancelProvisionerJob = async (job: TypesGen.ProvisionerJob) => {
2318+
switch (job.type) {
2319+
case "workspace_build":
2320+
if (!job.input.workspace_build_id) {
2321+
throw new Error("Workspace build ID is required to cancel this job");
2322+
}
2323+
return this.cancelWorkspaceBuild(job.input.workspace_build_id);
2324+
2325+
case "template_version_import":
2326+
if (!job.input.template_version_id) {
2327+
throw new Error("Template version ID is required to cancel this job");
2328+
}
2329+
return this.cancelTemplateVersionBuild(job.input.template_version_id);
2330+
2331+
case "template_version_dry_run":
2332+
if (!job.input.template_version_id) {
2333+
throw new Error("Template version ID is required to cancel this job");
2334+
}
2335+
return this.cancelTemplateVersionDryRun(
2336+
job.input.template_version_id,
2337+
job.id,
2338+
);
2339+
}
2340+
};
23052341
}
23062342

23072343
// This is a hard coded CSRF token/cookie pair for local development. In prod,

site/src/api/queries/organizations.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type {
33
AuthorizationResponse,
44
CreateOrganizationRequest,
55
GroupSyncSettings,
6-
ProvisionerDaemon,
76
RoleSyncSettings,
87
UpdateOrganizationRequest,
98
} from "api/typesGenerated";
@@ -245,9 +244,15 @@ export const organizationPermissions = (organizationId: string | undefined) => {
245244
};
246245
};
247246

247+
export const provisionerJobQueryKey = (orgId: string) => [
248+
"organization",
249+
orgId,
250+
"provisionerjobs",
251+
];
252+
248253
export const provisionerJobs = (orgId: string) => {
249254
return {
250-
queryKey: ["organization", orgId, "provisionerjobs"],
255+
queryKey: provisionerJobQueryKey(orgId),
251256
queryFn: () => API.getProvisionerJobs(orgId),
252257
};
253258
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { CancelJobButton } from "./CancelJobButton";
3+
import { MockProvisionerJob } from "testHelpers/entities";
4+
import { userEvent, waitFor, within } from "@storybook/test";
5+
6+
const meta: Meta<typeof CancelJobButton> = {
7+
title: "pages/OrganizationSettingsPage/ProvisionersPage/CancelJobButton",
8+
component: CancelJobButton,
9+
args: {
10+
job: {
11+
...MockProvisionerJob,
12+
status: "running",
13+
},
14+
},
15+
};
16+
17+
export default meta;
18+
type Story = StoryObj<typeof CancelJobButton>;
19+
20+
export const Cancellable: Story = {};
21+
22+
export const NotCancellable: Story = {
23+
args: {
24+
job: {
25+
...MockProvisionerJob,
26+
status: "succeeded",
27+
},
28+
},
29+
};
30+
31+
export const OnClick: Story = {
32+
parameters: {
33+
chromatic: { disableSnapshot: true },
34+
},
35+
play: async ({ canvasElement }) => {
36+
const user = userEvent.setup();
37+
const canvas = within(canvasElement);
38+
const button = canvas.getByRole("button");
39+
await user.click(button);
40+
41+
const body = within(canvasElement.ownerDocument.body);
42+
await waitFor(() => {
43+
body.getByText("Cancel provisioner job");
44+
});
45+
},
46+
};
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useState, type FC } from "react";
2+
import {
3+
Tooltip,
4+
TooltipContent,
5+
TooltipProvider,
6+
TooltipTrigger,
7+
} from "components/Tooltip/Tooltip";
8+
import { Button } from "components/Button/Button";
9+
import { BanIcon } from "lucide-react";
10+
import type { ProvisionerJob } from "api/typesGenerated";
11+
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
12+
13+
type CancelJobButtonProps = {
14+
job: ProvisionerJob;
15+
};
16+
17+
export const CancelJobButton: FC<CancelJobButtonProps> = ({ job }) => {
18+
const [isDialogOpen, setIsDialogOpen] = useState(false);
19+
const cancellable = ["pending", "running"].includes(job.status);
20+
21+
return (
22+
<>
23+
<TooltipProvider>
24+
<Tooltip>
25+
<TooltipTrigger asChild>
26+
<Button
27+
disabled={!cancellable}
28+
aria-label="Cancel job"
29+
size="icon"
30+
variant="outline"
31+
onClick={() => {
32+
setIsDialogOpen(true);
33+
}}
34+
>
35+
<BanIcon />
36+
</Button>
37+
</TooltipTrigger>
38+
<TooltipContent>Cancel job</TooltipContent>
39+
</Tooltip>
40+
</TooltipProvider>
41+
42+
<ConfirmDialog
43+
type="delete"
44+
onClose={(): void => {
45+
setIsDialogOpen(false);
46+
}}
47+
open={isDialogOpen}
48+
title="Cancel provisioner job"
49+
description={`Are you sure you want to cancel the provisioner job "${job.id}"? This operation will result in the associated workspaces not getting created.`}
50+
confirmText="Confirm"
51+
cancelText="Discard"
52+
/>
53+
</>
54+
);
55+
};
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { CancelJobConfirmationDialog } from "./CancelJobConfirmationDialog";
3+
import { MockProvisionerJob } from "testHelpers/entities";
4+
import { fn, userEvent, within, expect, waitFor } from "@storybook/test";
5+
import { withGlobalSnackbar } from "testHelpers/storybook";
6+
import type { Response } from "api/typesGenerated";
7+
8+
const meta: Meta<typeof CancelJobConfirmationDialog> = {
9+
title:
10+
"pages/OrganizationSettingsPage/ProvisionersPage/CancelJobConfirmationDialog",
11+
component: CancelJobConfirmationDialog,
12+
args: {
13+
open: true,
14+
onClose: fn(),
15+
cancelProvisionerJob: fn(),
16+
job: {
17+
...MockProvisionerJob,
18+
status: "running",
19+
},
20+
},
21+
};
22+
23+
export default meta;
24+
type Story = StoryObj<typeof CancelJobConfirmationDialog>;
25+
26+
export const Idle: Story = {};
27+
28+
export const OnCancel: Story = {
29+
parameters: {
30+
chromatic: { disableSnapshot: true },
31+
},
32+
play: async ({ canvasElement, args }) => {
33+
const user = userEvent.setup();
34+
const body = within(canvasElement.ownerDocument.body);
35+
const cancelButton = body.getByRole("button", { name: "Discard" });
36+
user.click(cancelButton);
37+
await waitFor(() => {
38+
expect(args.onClose).toHaveBeenCalledTimes(1);
39+
});
40+
},
41+
};
42+
43+
export const onConfirmSuccess: Story = {
44+
parameters: {
45+
chromatic: { disableSnapshot: true },
46+
},
47+
decorators: [withGlobalSnackbar],
48+
play: async ({ canvasElement, args }) => {
49+
const user = userEvent.setup();
50+
const body = within(canvasElement.ownerDocument.body);
51+
const confirmButton = body.getByRole("button", { name: "Confirm" });
52+
53+
user.click(confirmButton);
54+
await waitFor(() => {
55+
body.getByText("Provisioner job canceled successfully");
56+
});
57+
expect(args.cancelProvisionerJob).toHaveBeenCalledTimes(1);
58+
expect(args.cancelProvisionerJob).toHaveBeenCalledWith(args.job);
59+
expect(args.onClose).toHaveBeenCalledTimes(1);
60+
},
61+
};
62+
63+
export const onConfirmFailure: Story = {
64+
parameters: {
65+
chromatic: { disableSnapshot: true },
66+
},
67+
decorators: [withGlobalSnackbar],
68+
args: {
69+
cancelProvisionerJob: fn(() => {
70+
throw new Error("API Error");
71+
}),
72+
},
73+
play: async ({ canvasElement, args }) => {
74+
const user = userEvent.setup();
75+
const body = within(canvasElement.ownerDocument.body);
76+
const confirmButton = body.getByRole("button", { name: "Confirm" });
77+
78+
user.click(confirmButton);
79+
await waitFor(() => {
80+
body.getByText("Failed to cancel provisioner job");
81+
});
82+
expect(args.cancelProvisionerJob).toHaveBeenCalledTimes(1);
83+
expect(args.cancelProvisionerJob).toHaveBeenCalledWith(args.job);
84+
expect(args.onClose).toHaveBeenCalledTimes(0);
85+
},
86+
};
87+
88+
export const Confirming: Story = {
89+
args: {
90+
cancelProvisionerJob: fn(() => new Promise<Response>(() => {})),
91+
},
92+
play: async ({ canvasElement }) => {
93+
const user = userEvent.setup();
94+
const body = within(canvasElement.ownerDocument.body);
95+
const confirmButton = body.getByRole("button", { name: "Confirm" });
96+
user.click(confirmButton);
97+
},
98+
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { ProvisionerJob } from "api/typesGenerated";
2+
import {
3+
ConfirmDialog,
4+
type ConfirmDialogProps,
5+
} from "components/Dialogs/ConfirmDialog/ConfirmDialog";
6+
import type { FC } from "react";
7+
import { API } from "api/api";
8+
import { useMutation, useQueryClient } from "react-query";
9+
import {
10+
getProvisionerDaemonsKey,
11+
provisionerJobQueryKey,
12+
} from "api/queries/organizations";
13+
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
14+
15+
type CancelJobConfirmationDialogProps = Omit<
16+
ConfirmDialogProps,
17+
| "type"
18+
| "title"
19+
| "description"
20+
| "confirmText"
21+
| "cancelText"
22+
| "onConfirm"
23+
| "confirmLoading"
24+
> & {
25+
job: ProvisionerJob;
26+
cancelProvisionerJob: typeof API.cancelProvisionerJob;
27+
};
28+
29+
export const CancelJobConfirmationDialog: FC<
30+
CancelJobConfirmationDialogProps
31+
> = ({
32+
job,
33+
cancelProvisionerJob = API.cancelProvisionerJob,
34+
...dialogProps
35+
}) => {
36+
const queryClient = useQueryClient();
37+
const cancelMutation = useMutation({
38+
mutationFn: cancelProvisionerJob,
39+
onSuccess: () => {
40+
queryClient.invalidateQueries(
41+
provisionerJobQueryKey(job.organization_id),
42+
);
43+
queryClient.invalidateQueries(
44+
getProvisionerDaemonsKey(job.organization_id, job.tags),
45+
);
46+
},
47+
});
48+
49+
return (
50+
<ConfirmDialog
51+
{...dialogProps}
52+
type="delete"
53+
title="Cancel provisioner job"
54+
description={`Are you sure you want to cancel the provisioner job "${job.id}"? This operation will result in the associated workspaces not getting created.`}
55+
confirmText="Confirm"
56+
cancelText="Discard"
57+
confirmLoading={cancelMutation.isLoading}
58+
onConfirm={async () => {
59+
try {
60+
await cancelMutation.mutateAsync(job);
61+
displaySuccess("Provisioner job canceled successfully");
62+
dialogProps.onClose();
63+
} catch {
64+
displayError("Failed to cancel provisioner job");
65+
}
66+
}}
67+
/>
68+
);
69+
};

0 commit comments

Comments
 (0)