diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8dd9137890..70b3c631ec 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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( + `/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]), diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index adcdeb2bbf..f558956ef5 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -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; +}; + +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; + } + >; +}; diff --git a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx index a06b4ba3ab..b8be38a2ad 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.stories.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.stories.tsx @@ -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 = { @@ -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); + }, +}; diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index b17cbafec1..609ecad397 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -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 = ({ 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 = ({ // 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 = ({ }, }); - // 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 = ({ variant="outline" size="sm" onClick={handleRebuildDevcontainer} - disabled={devcontainer.status === "starting"} + disabled={isTransitioning} > - + - {devcontainer.container === undefined ? "Start" : "Rebuild"} + {rebuildButtonLabel(devcontainer)} {showDevcontainerControls && displayApps.includes("ssh_helper") && ( @@ -274,6 +263,18 @@ export const AgentDevcontainerCard: FC = ({ template={template} /> )} + + {showDevcontainerControls && ( + + )} + + @@ -382,3 +383,72 @@ export const AgentDevcontainerCard: FC = ({ ); }; + +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 = ({ + open, + error, + onClose, +}) => { + const errorDetail = getErrorDetail(error); + const errorMessage = getErrorMessage( + error, + "Failed to delete dev container.", + ); + + return ( + { + if (!isOpen) { + onClose(); + } + }} + > + + + Error deleting dev container + + Message{" "} + {errorMessage} + + {errorDetail && ( + + Detail{" "} + {/* TODO(DanielleMaywood): `[overflow-wrap:anywhere]` should be replaced with `wrap-anywhere` when we hit tailwind v4 */} + + {errorDetail} + + + )} + + + + + + + + + ); +}; diff --git a/site/src/modules/resources/AgentDevcontainerMoreActions.stories.tsx b/site/src/modules/resources/AgentDevcontainerMoreActions.stories.tsx new file mode 100644 index 0000000000..fa91470e4e --- /dev/null +++ b/site/src/modules/resources/AgentDevcontainerMoreActions.stories.tsx @@ -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 = { + title: "modules/resources/AgentDevcontainerMoreActions", + component: AgentDevcontainerMoreActions, + args: {}, +}; + +export default meta; +type Story = StoryObj; + +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); + }); + }, +}; diff --git a/site/src/modules/resources/AgentDevcontainerMoreActions.tsx b/site/src/modules/resources/AgentDevcontainerMoreActions.tsx new file mode 100644 index 0000000000..69136b8d18 --- /dev/null +++ b/site/src/modules/resources/AgentDevcontainerMoreActions.tsx @@ -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 ( + + + + + + + { + setIsConfirmingDelete(true); + }} + > + Delete… + + + + setIsConfirmingDelete(false)} + onConfirm={() => { + deleteDevContainer(); + setIsConfirmingDelete(false); + }} + /> + + ); +}; + +type DevcontainerDeleteDialogProps = { + isOpen: boolean; + onCancel: () => void; + onConfirm: () => void; +}; + +const DevcontainerDeleteDialog: FC = ({ + isOpen, + onCancel, + onConfirm, +}) => { + return ( + + Are you sure you want to delete this Dev Container? Any unsaved work + will be lost. +

+ } + /> + ); +}; diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx index 68d586ebcd..49a8abe4b5 100644 --- a/site/src/modules/resources/AgentRow.stories.tsx +++ b/site/src/modules/resources/AgentRow.stories.tsx @@ -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: [ { diff --git a/site/src/modules/resources/useAgentContainers.ts b/site/src/modules/resources/useAgentContainers.ts index 8437fbaed6..e986ef8feb 100644 --- a/site/src/modules/resources/useAgentContainers.ts +++ b/site/src/modules/resources/useAgentContainers.ts @@ -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); }, );