Skip to content

Commit ae79477

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

File tree

4 files changed

+212
-15
lines changed

4 files changed

+212
-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: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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+
// Snapshot the previous data for rollback in case of error.
45+
const previousData = queryClient.getQueryData([
46+
"agents",
47+
parentAgent.id,
48+
"containers",
49+
]);
50+
51+
// Optimistically update the devcontainer status to
52+
// "stopping" and zero the agent and container to mimic what
53+
// the API does.
54+
queryClient.setQueryData(
55+
["agents", parentAgent.id, "containers"],
56+
(oldData?: WorkspaceAgentListContainersResponse) => {
57+
if (!oldData?.devcontainers) return oldData;
58+
return {
59+
...oldData,
60+
devcontainers: oldData.devcontainers.map((dc) => {
61+
if (dc.id === devcontainer.id) {
62+
return {
63+
...dc,
64+
status: "stopping",
65+
container: undefined,
66+
};
67+
}
68+
return dc;
69+
}),
70+
};
71+
},
72+
);
73+
74+
return { previousData };
75+
},
76+
onError: (_, __, context) => {
77+
// If the mutation fails, use the context returned from
78+
// onMutate to roll back. The backend will set the error
79+
// field on the devcontainer which will be displayed inline.
80+
if (context?.previousData) {
81+
queryClient.setQueryData(
82+
["agents", parentAgent.id, "containers"],
83+
context.previousData,
84+
);
85+
}
86+
},
87+
});
88+
89+
return (
90+
<DropdownMenu open={open} onOpenChange={setOpen}>
91+
<DropdownMenuTrigger asChild>
92+
<Button size="icon-lg" variant="subtle" aria-controls={menuContentId}>
93+
<EllipsisVertical aria-hidden="true" />
94+
<span className="sr-only">Dev Container actions</span>
95+
</Button>
96+
</DropdownMenuTrigger>
97+
98+
<DropdownMenuContent id={menuContentId} align="end">
99+
<DropdownMenuItem
100+
className="text-content-destructive focus:text-content-destructive"
101+
onClick={() => {
102+
setIsConfirmingDelete(true);
103+
}}
104+
>
105+
Delete&hellip;
106+
</DropdownMenuItem>
107+
</DropdownMenuContent>
108+
109+
<DevcontainerDeleteDialog
110+
isOpen={isConfirmingDelete}
111+
onCancel={() => setIsConfirmingDelete(false)}
112+
onConfirm={() => {
113+
deleteDevContainerMutation.mutate();
114+
setIsConfirmingDelete(false);
115+
}}
116+
/>
117+
</DropdownMenu>
118+
);
119+
};
120+
121+
type DevcontainerDeleteDialogProps = {
122+
isOpen: boolean;
123+
onCancel: () => void;
124+
onConfirm: () => void;
125+
};
126+
127+
const DevcontainerDeleteDialog: FC<DevcontainerDeleteDialogProps> = ({
128+
isOpen,
129+
onCancel,
130+
onConfirm,
131+
}) => {
132+
return (
133+
<ConfirmDialog
134+
type="delete"
135+
open={isOpen}
136+
title="Delete Dev Container"
137+
onConfirm={onConfirm}
138+
onClose={onCancel}
139+
description={
140+
<p>
141+
Are you sure you want to delete this Dev Container? Any unsaved work
142+
will be lost.
143+
</p>
144+
}
145+
/>
146+
);
147+
};

0 commit comments

Comments
 (0)