mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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]),
|
||||
|
||||
@@ -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…
|
||||
</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);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user