feat(site): support deleting dev containers (#21249)

Closes https://github.com/coder/coder/issues/19062

Add logic to the frontend to allow deleting Dev Containers
This commit is contained in:
Danielle Maywood
2026-01-06 22:49:17 +00:00
committed by GitHub
parent 989def7a94
commit fa561bcd0a
8 changed files with 426 additions and 59 deletions
+25
View File
@@ -2641,6 +2641,31 @@ class ApiMethods {
}
};
deleteDevContainer = async ({
parentAgentId,
devcontainerId,
}: {
parentAgentId: string;
devcontainerId: string;
}) => {
await this.axios.delete(
`/api/v2/workspaceagents/${parentAgentId}/containers/devcontainers/${devcontainerId}`,
);
};
recreateDevContainer = async ({
parentAgentId,
devcontainerId,
}: {
parentAgentId: string;
devcontainerId: string;
}) => {
const response = await this.axios.post<TypesGen.Response>(
`/api/v2/workspaceagents/${parentAgentId}/containers/devcontainers/${devcontainerId}/recreate`,
);
return response.data;
};
getAgentContainers = async (agentId: string, labels?: string[]) => {
const params = new URLSearchParams(
labels?.map((label) => ["label", label]),
+75
View File
@@ -6,6 +6,9 @@ import type {
UsageAppName,
Workspace,
WorkspaceACL,
WorkspaceAgent,
WorkspaceAgentDevcontainer,
WorkspaceAgentListContainersResponse,
WorkspaceAgentLog,
WorkspaceBuild,
WorkspaceBuildParameter,
@@ -490,3 +493,75 @@ export const workspaceAgentCredentials = (
queryFn: () => API.getWorkspaceAgentCredentials(workspaceId, agentName),
};
};
export const workspaceAgentContainersKey = (agentId: string) => [
"agents",
agentId,
"containers",
];
export const workspaceAgentContainers = (agent: WorkspaceAgent) => {
return {
queryKey: workspaceAgentContainersKey(agent.id),
queryFn: () => API.getAgentContainers(agent.id),
enabled: agent.status === "connected",
} satisfies UseQueryOptions<WorkspaceAgentListContainersResponse>;
};
export const deleteWorkspaceAgentDevcontainer = (
parentAgent: WorkspaceAgent,
devcontainer: WorkspaceAgentDevcontainer,
queryClient: QueryClient,
) => {
const queryKey = workspaceAgentContainersKey(parentAgent.id);
return {
mutationFn: async () => {
await API.deleteDevContainer({
parentAgentId: parentAgent.id,
devcontainerId: devcontainer.id,
});
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
queryClient.setQueryData(
queryKey,
(oldData?: WorkspaceAgentListContainersResponse) => {
if (!oldData?.devcontainers) {
return oldData;
}
return {
...oldData,
devcontainers: oldData.devcontainers.map((dc) => {
if (dc.id === devcontainer.id) {
return {
...dc,
status: "stopping",
container: undefined,
};
}
return dc;
}),
};
},
);
return { previousData };
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
},
} satisfies UseMutationOptions<
void,
Error,
void,
{
previousData: unknown;
}
>;
};
@@ -11,13 +11,16 @@ import {
MockWorkspaceApp,
MockWorkspaceProxies,
MockWorkspaceSubAgent,
mockApiError,
} from "testHelpers/entities";
import {
withDashboardProvider,
withProxyProvider,
} from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { getPreferredProxy } from "contexts/ProxyContext";
import { spyOn, userEvent, within } from "storybook/test";
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
const meta: Meta<typeof AgentDevcontainerCard> = {
@@ -91,6 +94,26 @@ export const Recreating: Story = {
},
};
export const Stopping: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
status: "stopping",
},
subAgents: [],
},
};
export const Deleting: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
status: "deleting",
},
subAgents: [],
},
};
export const NoContainerOrSubAgent: Story = {
args: {
devcontainer: {
@@ -161,3 +184,33 @@ export const WithPortForwarding: Story = {
}),
],
};
export const WithDeleteError: Story = {
beforeEach: () => {
spyOn(API, "deleteDevContainer").mockRejectedValue(
mockApiError({
message: "An error occurred stopping the container",
detail:
"stop ba5eb2bc1cc415a57552f7f1fd369ad13cbebe70030d46aa9b3b9253b383a81c: exit status 1: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?: exit status 1",
}),
);
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const body = canvasElement.ownerDocument.body;
const canvas = within(canvasElement);
const moreActionsButton = canvas.getByRole("button", {
name: "Dev Container actions",
});
await user.click(moreActionsButton);
const deleteButton = await within(body).findByText("Delete…");
await user.click(deleteButton);
const confirmDeleteButton = within(body).getByRole("button", {
name: "Delete",
});
await user.click(confirmDeleteButton);
},
};
@@ -1,4 +1,10 @@
import Skeleton from "@mui/material/Skeleton";
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import {
deleteWorkspaceAgentDevcontainer,
workspaceAgentContainersKey,
} from "api/queries/workspaces";
import type {
Template,
Workspace,
@@ -6,8 +12,16 @@ import type {
WorkspaceAgentDevcontainer,
WorkspaceAgentListContainersResponse,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
import { displayError } from "components/GlobalSnackbar/utils";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
@@ -21,12 +35,12 @@ import { Container, ExternalLinkIcon } from "lucide-react";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
import type { FC } from "react";
import { useEffect } from "react";
import { useMutation, useQueryClient } from "react-query";
import { cn } from "utils/cn";
import { portForwardURL } from "utils/portForward";
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
import { AgentButton } from "./AgentButton";
import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions";
import { AgentLatency } from "./AgentLatency";
import { DevcontainerStatus } from "./AgentStatus";
import { PortForwardButton } from "./PortForwardButton";
@@ -78,37 +92,30 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
showVSCode ||
appSections.some((it) => it.apps.length > 0);
const queryKey = workspaceAgentContainersKey(parentAgent.id);
const deleteDevcontainerMutation = useMutation({
...deleteWorkspaceAgentDevcontainer(parentAgent, devcontainer, queryClient),
});
const rebuildDevcontainerMutation = useMutation({
mutationFn: async () => {
const response = await fetch(
`/api/v2/workspaceagents/${parentAgent.id}/containers/devcontainers/${devcontainer.id}/recreate`,
{ method: "POST" },
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `Failed to rebuild: ${response.statusText}`,
);
}
return response;
await API.recreateDevContainer({
parentAgentId: parentAgent.id,
devcontainerId: devcontainer.id,
});
},
onMutate: async () => {
await queryClient.cancelQueries({
queryKey: ["agents", parentAgent.id, "containers"],
});
await queryClient.cancelQueries({ queryKey });
// Snapshot the previous data for rollback in case of error.
const previousData = queryClient.getQueryData([
"agents",
parentAgent.id,
"containers",
]);
const previousData = queryClient.getQueryData(queryKey);
// Optimistically update the devcontainer status to
// "starting" and zero the agent and container to mimic what
// the API does.
queryClient.setQueryData(
["agents", parentAgent.id, "containers"],
queryKey,
(oldData?: WorkspaceAgentListContainersResponse) => {
if (!oldData?.devcontainers) return oldData;
return {
@@ -133,11 +140,9 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
// If the mutation fails, use the context returned from
// onMutate to roll back.
if (context?.previousData) {
queryClient.setQueryData(
["agents", parentAgent.id, "containers"],
context.previousData,
);
queryClient.setQueryData(queryKey, context.previousData);
}
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred.";
displayError(`Failed to rebuild devcontainer: ${errorMessage}`);
@@ -145,29 +150,13 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
},
});
// Re-fetch containers when the subAgent changes to ensure data is
// in sync. This relies on agent updates being pushed to the client
// to trigger the re-fetch. That is why we match on name here
// instead of ID as we need to fetch to get an up-to-date ID.
const latestSubAgentByName = subAgents.find(
(agent) => agent.name === devcontainer.name,
);
useEffect(() => {
if (!latestSubAgentByName?.id || !latestSubAgentByName?.status) {
return;
}
queryClient.invalidateQueries({
queryKey: ["agents", parentAgent.id, "containers"],
});
}, [
latestSubAgentByName?.id,
latestSubAgentByName?.status,
queryClient,
parentAgent.id,
]);
const showDevcontainerControls = subAgent && devcontainer.container;
const isTransitioning =
devcontainer.status === "starting" ||
devcontainer.status === "stopping" ||
devcontainer.status === "deleting";
const showSubAgentApps =
devcontainer.status !== "deleting" &&
devcontainer.status !== "starting" &&
subAgent?.status === "connected" &&
hasAppsToDisplay;
@@ -250,11 +239,11 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
variant="outline"
size="sm"
onClick={handleRebuildDevcontainer}
disabled={devcontainer.status === "starting"}
disabled={isTransitioning}
>
<Spinner loading={devcontainer.status === "starting"} />
<Spinner loading={isTransitioning} />
{devcontainer.container === undefined ? "Start" : "Rebuild"}
{rebuildButtonLabel(devcontainer)}
</Button>
{showDevcontainerControls && displayApps.includes("ssh_helper") && (
@@ -274,6 +263,18 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
template={template}
/>
)}
{showDevcontainerControls && (
<AgentDevcontainerMoreActions
deleteDevContainer={deleteDevcontainerMutation.mutate}
/>
)}
<DevcontainerDeleteErrorDialog
open={deleteDevcontainerMutation.isError}
error={deleteDevcontainerMutation.error}
onClose={deleteDevcontainerMutation.reset}
/>
</div>
</header>
@@ -382,3 +383,72 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
</Stack>
);
};
function rebuildButtonLabel(devcontainer: WorkspaceAgentDevcontainer) {
switch (devcontainer.status) {
case "deleting":
return "Deleting";
case "stopping":
return "Stopping";
default:
if (devcontainer.container) {
return "Rebuild";
}
return "Start";
}
}
type DevcontainerDeleteErrorDialogProps = {
open: boolean;
error?: unknown;
onClose: () => void;
};
const DevcontainerDeleteErrorDialog: FC<DevcontainerDeleteErrorDialogProps> = ({
open,
error,
onClose,
}) => {
const errorDetail = getErrorDetail(error);
const errorMessage = getErrorMessage(
error,
"Failed to delete dev container.",
);
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
onClose();
}
}}
>
<DialogContent variant="destructive">
<DialogHeader>
<DialogTitle>Error deleting dev container</DialogTitle>
<DialogDescription className="flex flex-row gap-4">
<strong className="text-content-primary">Message</strong>{" "}
<span>{errorMessage}</span>
</DialogDescription>
{errorDetail && (
<DialogDescription className="flex flex-row gap-9">
<strong className="text-content-primary">Detail</strong>{" "}
{/* TODO(DanielleMaywood): `[overflow-wrap:anywhere]` should be replaced with `wrap-anywhere` when we hit tailwind v4 */}
<span className="[overflow-wrap:anywhere] break-normal">
{errorDetail}
</span>
</DialogDescription>
)}
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button>Ok</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions";
const meta: Meta<typeof AgentDevcontainerMoreActions> = {
title: "modules/resources/AgentDevcontainerMoreActions",
component: AgentDevcontainerMoreActions,
args: {},
};
export default meta;
type Story = StoryObj<typeof AgentDevcontainerMoreActions>;
export const Default: Story = {};
export const MenuOpen: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
await user.click(
canvas.getByRole("button", { name: "Dev Container actions" }),
);
},
};
export const ConfirmDialogOpen: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
await user.click(
canvas.getByRole("button", { name: "Dev Container actions" }),
);
const body = canvasElement.ownerDocument.body;
await user.click(await within(body).findByText("Delete…"));
},
};
export const ConfirmDeleteCallsAPI: Story = {
args: {
deleteDevContainer: fn(),
},
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
await user.click(
canvas.getByRole("button", { name: "Dev Container actions" }),
);
const body = canvasElement.ownerDocument.body;
await user.click(await within(body).findByText("Delete…"));
await user.click(within(body).getByTestId("confirm-button"));
await waitFor(() => {
expect(args.deleteDevContainer).toHaveBeenCalledTimes(1);
});
},
};
@@ -0,0 +1,81 @@
import { Button } from "components/Button/Button";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EllipsisVertical } from "lucide-react";
import { type FC, useId, useState } from "react";
type AgentDevcontainerMoreActionsProps = {
deleteDevContainer: () => void;
};
export const AgentDevcontainerMoreActions: FC<
AgentDevcontainerMoreActionsProps
> = ({ deleteDevContainer }) => {
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const [open, setOpen] = useState(false);
const menuContentId = useId();
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button size="icon-lg" variant="subtle" aria-controls={menuContentId}>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Dev Container actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent id={menuContentId} align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => {
setIsConfirmingDelete(true);
}}
>
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
<DevcontainerDeleteDialog
isOpen={isConfirmingDelete}
onCancel={() => setIsConfirmingDelete(false)}
onConfirm={() => {
deleteDevContainer();
setIsConfirmingDelete(false);
}}
/>
</DropdownMenu>
);
};
type DevcontainerDeleteDialogProps = {
isOpen: boolean;
onCancel: () => void;
onConfirm: () => void;
};
const DevcontainerDeleteDialog: FC<DevcontainerDeleteDialogProps> = ({
isOpen,
onCancel,
onConfirm,
}) => {
return (
<ConfirmDialog
type="delete"
open={isOpen}
title="Delete Dev Container"
onConfirm={onConfirm}
onClose={onCancel}
description={
<p>
Are you sure you want to delete this Dev Container? Any unsaved work
will be lost.
</p>
}
/>
);
};
@@ -7,6 +7,7 @@ import {
} from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { workspaceAgentContainersKey } from "api/queries/workspaces";
import { getPreferredProxy } from "contexts/ProxyContext";
import { spyOn, userEvent, within } from "storybook/test";
import { AgentRow } from "./AgentRow";
@@ -296,7 +297,7 @@ export const Devcontainer: Story = {
parameters: {
queries: [
{
key: ["agents", M.MockWorkspaceAgent.id, "containers"],
key: workspaceAgentContainersKey(M.MockWorkspaceAgent.id),
data: {
devcontainers: [M.MockWorkspaceAgentDevcontainer],
containers: [M.MockWorkspaceAgentContainer],
@@ -316,7 +317,7 @@ export const FoundDevcontainer: Story = {
parameters: {
queries: [
{
key: ["agents", M.MockWorkspaceAgentReady.id, "containers"],
key: workspaceAgentContainersKey(M.MockWorkspaceAgentReady.id),
data: {
devcontainers: [
{
@@ -1,4 +1,8 @@
import { API, watchAgentContainers } from "api/api";
import { watchAgentContainers } from "api/api";
import {
workspaceAgentContainers,
workspaceAgentContainersKey,
} from "api/queries/workspaces";
import type {
WorkspaceAgent,
WorkspaceAgentDevcontainer,
@@ -13,23 +17,19 @@ export function useAgentContainers(
agent: WorkspaceAgent,
): readonly WorkspaceAgentDevcontainer[] | undefined {
const queryClient = useQueryClient();
const queryKey = workspaceAgentContainersKey(agent.id);
const {
data: devcontainers,
error: queryError,
isLoading: queryIsLoading,
} = useQuery({
queryKey: ["agents", agent.id, "containers"],
queryFn: () => API.getAgentContainers(agent.id),
enabled: agent.status === "connected",
...workspaceAgentContainers(agent),
select: (res) => res.devcontainers,
staleTime: Number.POSITIVE_INFINITY,
});
const updateDevcontainersCache = useEffectEvent(
async (data: WorkspaceAgentListContainersResponse) => {
const queryKey = ["agents", agent.id, "containers"];
queryClient.setQueryData(queryKey, data);
},
);