Skip to content

Commit 0aa2d5b

Browse files
feat(site): support deleting dev containers
1 parent 265aeb8 commit 0aa2d5b

File tree

5 files changed

+285
-15
lines changed

5 files changed

+285
-15
lines changed

site/src/api/api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2631,6 +2631,31 @@ class ApiMethods {
26312631
}
26322632
};
26332633

2634+
deleteDevContainer = async ({
2635+
parentAgentId,
2636+
devcontainerId,
2637+
}: {
2638+
parentAgentId: string;
2639+
devcontainerId: string;
2640+
}) => {
2641+
await this.axios.delete(
2642+
`/api/v2/workspaceagents/${parentAgentId}/containers/devcontainers/${devcontainerId}`,
2643+
);
2644+
};
2645+
2646+
recreateDevContainer = async ({
2647+
parentAgentId,
2648+
devcontainerId,
2649+
}: {
2650+
parentAgentId: string;
2651+
devcontainerId: string;
2652+
}) => {
2653+
const response = await this.axios.post<TypesGen.Response>(
2654+
`/api/v2/workspaceagents/${parentAgentId}/containers/devcontainers/${devcontainerId}/recreate`,
2655+
);
2656+
return response.data;
2657+
};
2658+
26342659
getAgentContainers = async (agentId: string, labels?: string[]) => {
26352660
const params = new URLSearchParams(
26362661
labels?.map((label) => ["label", label]),

site/src/modules/resources/AgentDevcontainerCard.stories.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ export const Recreating: Story = {
9191
},
9292
};
9393

94+
export const Stopping: Story = {
95+
args: {
96+
devcontainer: {
97+
...MockWorkspaceAgentDevcontainer,
98+
dirty: true,
99+
status: "stopping",
100+
},
101+
subAgents: [],
102+
},
103+
};
104+
94105
export const NoContainerOrSubAgent: Story = {
95106
args: {
96107
devcontainer: {

site/src/modules/resources/AgentDevcontainerCard.tsx

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import Skeleton from "@mui/material/Skeleton";
2+
import { API } from "api/api";
23
import type {
34
Template,
45
Workspace,
56
WorkspaceAgent,
67
WorkspaceAgentDevcontainer,
78
WorkspaceAgentListContainersResponse,
89
} from "api/typesGenerated";
9-
1010
import { Button } from "components/Button/Button";
1111
import { displayError } from "components/GlobalSnackbar/utils";
1212
import { Spinner } from "components/Spinner/Spinner";
@@ -27,6 +27,7 @@ import { cn } from "utils/cn";
2727
import { portForwardURL } from "utils/portForward";
2828
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
2929
import { AgentButton } from "./AgentButton";
30+
import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions";
3031
import { AgentLatency } from "./AgentLatency";
3132
import { DevcontainerStatus } from "./AgentStatus";
3233
import { PortForwardButton } from "./PortForwardButton";
@@ -80,17 +81,10 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
8081

8182
const rebuildDevcontainerMutation = useMutation({
8283
mutationFn: async () => {
83-
const response = await fetch(
84-
`/api/v2/workspaceagents/${parentAgent.id}/containers/devcontainers/${devcontainer.id}/recreate`,
85-
{ method: "POST" },
86-
);
87-
if (!response.ok) {
88-
const errorData = await response.json().catch(() => ({}));
89-
throw new Error(
90-
errorData.message || `Failed to rebuild: ${response.statusText}`,
91-
);
92-
}
93-
return response;
84+
await API.recreateDevContainer({
85+
parentAgentId: parentAgent.id,
86+
devcontainerId: devcontainer.id,
87+
});
9488
},
9589
onMutate: async () => {
9690
await queryClient.cancelQueries({
@@ -168,6 +162,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
168162

169163
const showDevcontainerControls = subAgent && devcontainer.container;
170164
const showSubAgentApps =
165+
devcontainer.status !== "stopping" &&
171166
devcontainer.status !== "starting" &&
172167
subAgent?.status === "connected" &&
173168
hasAppsToDisplay;
@@ -250,11 +245,23 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
250245
variant="outline"
251246
size="sm"
252247
onClick={handleRebuildDevcontainer}
253-
disabled={devcontainer.status === "starting"}
248+
disabled={
249+
devcontainer.status === "starting" ||
250+
devcontainer.status === "stopping"
251+
}
254252
>
255-
<Spinner loading={devcontainer.status === "starting"} />
253+
<Spinner
254+
loading={
255+
devcontainer.status === "starting" ||
256+
devcontainer.status === "stopping"
257+
}
258+
/>
256259

257-
{devcontainer.container === undefined ? "Start" : "Rebuild"}
260+
{devcontainer.status === "stopping"
261+
? "Stop"
262+
: devcontainer.container === undefined
263+
? "Start"
264+
: "Rebuild"}
258265
</Button>
259266

260267
{showDevcontainerControls && displayApps.includes("ssh_helper") && (
@@ -274,6 +281,13 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
274281
template={template}
275282
/>
276283
)}
284+
285+
{showDevcontainerControls && (
286+
<AgentDevcontainerMoreActions
287+
devcontainer={devcontainer}
288+
parentAgent={parentAgent}
289+
/>
290+
)}
277291
</div>
278292
</header>
279293

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
MockWorkspaceAgent,
3+
MockWorkspaceAgentDevcontainer,
4+
} from "testHelpers/entities";
5+
import type { Meta, StoryObj } from "@storybook/react-vite";
6+
import { API } from "api/api";
7+
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
8+
import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions";
9+
10+
const meta: Meta<typeof AgentDevcontainerMoreActions> = {
11+
title: "modules/resources/AgentDevcontainerMoreActions",
12+
component: AgentDevcontainerMoreActions,
13+
args: {
14+
parentAgent: MockWorkspaceAgent,
15+
devcontainer: MockWorkspaceAgentDevcontainer,
16+
},
17+
};
18+
19+
export default meta;
20+
type Story = StoryObj<typeof AgentDevcontainerMoreActions>;
21+
22+
export const Default: Story = {};
23+
24+
export const MenuOpen: Story = {
25+
play: async ({ canvasElement }) => {
26+
const user = userEvent.setup();
27+
const canvas = within(canvasElement);
28+
29+
await user.click(
30+
canvas.getByRole("button", { name: "Dev Container actions" }),
31+
);
32+
33+
const body = canvasElement.ownerDocument.body;
34+
await within(body).findByText("Delete…");
35+
},
36+
};
37+
38+
export const ConfirmDialogOpen: Story = {
39+
play: async ({ canvasElement }) => {
40+
const user = userEvent.setup();
41+
const canvas = within(canvasElement);
42+
43+
await user.click(
44+
canvas.getByRole("button", { name: "Dev Container actions" }),
45+
);
46+
47+
const body = canvasElement.ownerDocument.body;
48+
await user.click(await within(body).findByText("Delete…"));
49+
50+
await within(body).findByText("Delete Dev Container");
51+
},
52+
};
53+
54+
export const ConfirmDeleteCallsAPI: Story = {
55+
play: async ({ canvasElement, args }) => {
56+
const user = userEvent.setup();
57+
const canvas = within(canvasElement);
58+
59+
const deleteSpy = spyOn(API, "deleteDevContainer").mockResolvedValue(
60+
undefined as never,
61+
);
62+
63+
await user.click(
64+
canvas.getByRole("button", { name: "Dev Container actions" }),
65+
);
66+
67+
const body = canvasElement.ownerDocument.body;
68+
await user.click(await within(body).findByText("Delete…"));
69+
70+
await user.click(within(body).getByTestId("confirm-button"));
71+
72+
await waitFor(() => {
73+
expect(deleteSpy).toHaveBeenCalledTimes(1);
74+
expect(deleteSpy).toHaveBeenCalledWith({
75+
parentAgentId: args.parentAgent.id,
76+
devcontainerId: args.devcontainer.id,
77+
});
78+
});
79+
},
80+
};
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { API } from "api/api";
2+
import type {
3+
WorkspaceAgent,
4+
WorkspaceAgentDevcontainer,
5+
WorkspaceAgentListContainersResponse,
6+
} from "api/typesGenerated";
7+
import { Button } from "components/Button/Button";
8+
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
9+
import {
10+
DropdownMenu,
11+
DropdownMenuContent,
12+
DropdownMenuItem,
13+
DropdownMenuTrigger,
14+
} from "components/DropdownMenu/DropdownMenu";
15+
import { EllipsisVertical } from "lucide-react";
16+
import { type FC, useId, useState } from "react";
17+
import { useMutation, useQueryClient } from "react-query";
18+
19+
type AgentDevcontainerMoreActionsProps = {
20+
parentAgent: WorkspaceAgent;
21+
devcontainer: WorkspaceAgentDevcontainer;
22+
};
23+
24+
export const AgentDevcontainerMoreActions: FC<
25+
AgentDevcontainerMoreActionsProps
26+
> = ({ parentAgent, devcontainer }) => {
27+
const queryClient = useQueryClient();
28+
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
29+
const [open, setOpen] = useState(false);
30+
const menuContentId = useId();
31+
32+
const deleteDevContainerMutation = useMutation({
33+
mutationFn: async () => {
34+
await API.deleteDevContainer({
35+
parentAgentId: parentAgent.id,
36+
devcontainerId: devcontainer.id,
37+
});
38+
},
39+
onMutate: async () => {
40+
await queryClient.cancelQueries({
41+
queryKey: ["agents", parentAgent.id, "containers"],
42+
});
43+
44+
const previousData = queryClient.getQueryData([
45+
"agents",
46+
parentAgent.id,
47+
"containers",
48+
]);
49+
50+
queryClient.setQueryData(
51+
["agents", parentAgent.id, "containers"],
52+
(oldData?: WorkspaceAgentListContainersResponse) => {
53+
if (!oldData?.devcontainers) return oldData;
54+
return {
55+
...oldData,
56+
devcontainers: oldData.devcontainers.map((dc) => {
57+
if (dc.id === devcontainer.id) {
58+
return {
59+
...dc,
60+
status: "stopping",
61+
container: undefined,
62+
};
63+
}
64+
return dc;
65+
}),
66+
};
67+
},
68+
);
69+
70+
return { previousData };
71+
},
72+
onError: (_, __, context) => {
73+
if (context?.previousData) {
74+
queryClient.setQueryData(
75+
["agents", parentAgent.id, "containers"],
76+
context.previousData,
77+
);
78+
}
79+
},
80+
});
81+
82+
return (
83+
<DropdownMenu open={open} onOpenChange={setOpen}>
84+
<DropdownMenuTrigger asChild>
85+
<Button size="icon-lg" variant="subtle" aria-controls={menuContentId}>
86+
<EllipsisVertical aria-hidden="true" />
87+
<span className="sr-only">Dev Container actions</span>
88+
</Button>
89+
</DropdownMenuTrigger>
90+
91+
<DropdownMenuContent id={menuContentId} align="end">
92+
<DropdownMenuItem
93+
className="text-content-destructive focus:text-content-destructive"
94+
onClick={() => {
95+
setIsConfirmingDelete(true);
96+
}}
97+
>
98+
Delete&hellip;
99+
</DropdownMenuItem>
100+
</DropdownMenuContent>
101+
102+
<DevcontainerDeleteDialog
103+
isOpen={isConfirmingDelete}
104+
onCancel={() => setIsConfirmingDelete(false)}
105+
onConfirm={() => {
106+
deleteDevContainerMutation.mutate();
107+
setIsConfirmingDelete(false);
108+
}}
109+
/>
110+
</DropdownMenu>
111+
);
112+
};
113+
114+
type DevcontainerDeleteDialogProps = {
115+
isOpen: boolean;
116+
onCancel: () => void;
117+
onConfirm: () => void;
118+
};
119+
120+
const DevcontainerDeleteDialog: FC<DevcontainerDeleteDialogProps> = ({
121+
isOpen,
122+
onCancel,
123+
onConfirm,
124+
}) => {
125+
return (
126+
<ConfirmDialog
127+
type="delete"
128+
open={isOpen}
129+
title="Delete Dev Container"
130+
onConfirm={onConfirm}
131+
onClose={onCancel}
132+
description={
133+
<p>
134+
Are you sure you want to delete this Dev Container? Any unsaved work
135+
will be lost.
136+
</p>
137+
}
138+
/>
139+
);
140+
};

0 commit comments

Comments
 (0)