feat: batch workspace updates (#11583)

This commit is contained in:
Kayla Washburn-Love
2024-01-18 15:14:25 -07:00
committed by GitHub
parent 156aaba335
commit 9ed3487f67
16 changed files with 893 additions and 84 deletions
@@ -66,7 +66,7 @@ const styles = {
}),
dialogContent: (theme) => ({
color: theme.palette.text.secondary,
padding: 40,
padding: "40px 40px 20px",
}),
dialogTitle: (theme) => ({
margin: 0,
+2 -2
View File
@@ -80,8 +80,8 @@ const styles = {
},
"&:hover:not(:disabled)": {
backgroundColor: theme.experimental.roles.danger.disabled.fill,
borderColor: theme.experimental.roles.danger.disabled.outline,
backgroundColor: theme.experimental.roles.danger.hover.fill,
borderColor: theme.experimental.roles.danger.hover.outline,
},
"&.Mui-disabled": {
@@ -47,9 +47,13 @@ export const WorkspaceOutdatedTooltip: FC<TooltipProps> = (props) => {
);
};
export const WorkspaceOutdatedTooltipContent = (props: TooltipProps) => {
export const WorkspaceOutdatedTooltipContent: FC<TooltipProps> = ({
onUpdateVersion,
ariaLabel,
latestVersionId,
templateName,
}) => {
const popover = usePopover();
const { onUpdateVersion, ariaLabel, latestVersionId, templateName } = props;
const { data: activeVersion } = useQuery({
...templateVersion(latestVersionId),
enabled: popover.isOpen,
@@ -32,8 +32,6 @@ export const DeleteDialog: Story = {
args: {
queryKey: ["tokens"],
token: MockToken,
setToken: () => {
return null;
},
setToken: () => null,
},
};
@@ -1,6 +1,6 @@
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
import { FC, useEffect, useState } from "react";
import { type FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate } from "react-router-dom";
import { Workspace } from "./Workspace";
@@ -42,11 +42,11 @@ interface WorkspaceReadyPageProps {
permissions: WorkspacePermissions;
}
export const WorkspaceReadyPage = ({
export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
workspace,
template,
permissions,
}: WorkspaceReadyPageProps): JSX.Element => {
}) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { buildInfo } = useDashboard();
@@ -1,10 +1,12 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { chromatic } from "testHelpers/chromatic";
import { MockWorkspace, MockUser2 } from "testHelpers/entities";
import { BatchDeleteConfirmation } from "./BatchActions";
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
const meta: Meta<typeof BatchDeleteConfirmation> = {
title: "pages/WorkspacesPage/BatchDelete",
title: "pages/WorkspacesPage/BatchDeleteConfirmation",
parameters: { chromatic },
component: BatchDeleteConfirmation,
args: {
onClose: action("onClose"),
@@ -35,4 +37,4 @@ type Story = StoryObj<typeof BatchDeleteConfirmation>;
const Example: Story = {};
export { Example as BatchDelete };
export { Example as BatchDeleteConfirmation };
@@ -2,68 +2,15 @@ import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined";
import ScheduleIcon from "@mui/icons-material/Schedule";
import { visuallyHidden } from "@mui/utils";
import dayjs from "dayjs";
import "dayjs/plugin/relativeTime";
import { type Interpolation, type Theme } from "@emotion/react";
import relativeTime from "dayjs/plugin/relativeTime";
import { useTheme, type Interpolation, type Theme } from "@emotion/react";
import { type FC, type ReactNode, useState } from "react";
import { useMutation } from "react-query";
import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api";
import type { Workspace } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { displayError } from "components/GlobalSnackbar/utils";
import { getResourceIconPath } from "utils/workspace";
import { Stack } from "components/Stack/Stack";
import { getResourceIconPath } from "utils/workspace";
interface UseBatchActionsProps {
onSuccess: () => Promise<void>;
}
export function useBatchActions(options: UseBatchActionsProps) {
const { onSuccess } = options;
const startAllMutation = useMutation({
mutationFn: async (workspaces: Workspace[]) => {
return Promise.all(
workspaces.map((w) =>
startWorkspace(w.id, w.latest_build.template_version_id),
),
);
},
onSuccess,
onError: () => {
displayError("Failed to start workspaces");
},
});
const stopAllMutation = useMutation({
mutationFn: async (workspaces: Workspace[]) => {
return Promise.all(workspaces.map((w) => stopWorkspace(w.id)));
},
onSuccess,
onError: () => {
displayError("Failed to stop workspaces");
},
});
const deleteAllMutation = useMutation({
mutationFn: async (workspaces: Workspace[]) => {
return Promise.all(workspaces.map((w) => deleteWorkspace(w.id)));
},
onSuccess,
onError: () => {
displayError("Failed to delete workspaces");
},
});
return {
startAll: startAllMutation.mutateAsync,
stopAll: stopAllMutation.mutateAsync,
deleteAll: deleteAllMutation.mutateAsync,
isLoading:
startAllMutation.isLoading ||
stopAllMutation.isLoading ||
deleteAllMutation.isLoading,
};
}
dayjs.extend(relativeTime);
type BatchDeleteConfirmationProps = {
checkedWorkspaces: Workspace[];
@@ -182,6 +129,8 @@ const Consequences: FC = () => {
};
const Workspaces: FC<StageProps> = ({ workspaces }) => {
const theme = useTheme();
const mostRecent = workspaces.reduce(
(latestSoFar, against) => {
if (!latestSoFar) {
@@ -209,7 +158,9 @@ const Workspaces: FC<StageProps> = ({ workspaces }) => {
alignItems="center"
justifyContent="space-between"
>
<span css={{ fontWeight: 500, color: "#fff" }}>
<span
css={{ fontWeight: 500, color: theme.experimental.l1.text }}
>
{workspace.name}
</span>
<Stack css={{ gap: 0, fontSize: 14, width: 128 }}>
@@ -234,7 +185,12 @@ const Workspaces: FC<StageProps> = ({ workspaces }) => {
</li>
))}
</ul>
<Stack justifyContent="center" direction="row" css={{ fontSize: 14 }}>
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={{ gap: "6px 20px", fontSize: 14 }}
>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span>{ownersCount}</span>
@@ -0,0 +1,75 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { useQueryClient } from "react-query";
import { chromatic } from "testHelpers/chromatic";
import {
MockWorkspace,
MockRunningOutdatedWorkspace,
MockDormantOutdatedWorkspace,
MockOutdatedWorkspace,
MockTemplateVersion,
MockUser2,
} from "testHelpers/entities";
import {
BatchUpdateConfirmation,
type Update,
} from "./BatchUpdateConfirmation";
const workspaces = [
{ ...MockRunningOutdatedWorkspace, id: "1" },
{ ...MockDormantOutdatedWorkspace, id: "2" },
{ ...MockOutdatedWorkspace, id: "3" },
{ ...MockOutdatedWorkspace, id: "4" },
{ ...MockWorkspace, id: "5" },
{
...MockRunningOutdatedWorkspace,
id: "6",
owner_id: MockUser2.id,
owner_name: MockUser2.username,
},
];
const updates = new Map<string, Update>();
for (const it of workspaces) {
const versionId = it.template_active_version_id;
const version = updates.get(versionId);
if (version) {
version.affected_workspaces.push(it);
continue;
}
updates.set(versionId, {
...MockTemplateVersion,
template_display_name: it.template_display_name,
affected_workspaces: [it],
});
}
const meta: Meta<typeof BatchUpdateConfirmation> = {
title: "pages/WorkspacesPage/BatchUpdateConfirmation",
parameters: { chromatic },
component: BatchUpdateConfirmation,
decorators: [
(Story) => {
const queryClient = useQueryClient();
for (const [id, it] of updates) {
queryClient.setQueryData(["batchUpdate", id], it);
}
return <Story />;
},
],
args: {
onClose: action("onClose"),
onConfirm: action("onConfirm"),
open: true,
checkedWorkspaces: workspaces,
},
};
export default meta;
type Story = StoryObj<typeof BatchUpdateConfirmation>;
const Example: Story = {};
export { Example as BatchUpdateConfirmation };
@@ -0,0 +1,491 @@
import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined";
import ScheduleIcon from "@mui/icons-material/Schedule";
import InstallDesktopIcon from "@mui/icons-material/InstallDesktop";
import SettingsSuggestIcon from "@mui/icons-material/SettingsSuggest";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { type Interpolation, type Theme } from "@emotion/react";
import { type FC, type ReactNode, useMemo, useState, useEffect } from "react";
import { useQueries } from "react-query";
import { getTemplateVersion } from "api/api";
import type { TemplateVersion, Workspace } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Stack } from "components/Stack/Stack";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
dayjs.extend(relativeTime);
type BatchUpdateConfirmationProps = {
checkedWorkspaces: Workspace[];
open: boolean;
isLoading: boolean;
onClose: () => void;
onConfirm: () => void;
};
export interface Update extends TemplateVersion {
template_display_name: string;
affected_workspaces: Workspace[];
}
export const BatchUpdateConfirmation: FC<BatchUpdateConfirmationProps> = ({
checkedWorkspaces,
open,
onClose,
onConfirm,
isLoading,
}) => {
// Ignore workspaces with no pending update
const outdatedWorkspaces = useMemo(
() => checkedWorkspaces.filter((workspace) => workspace.outdated),
[checkedWorkspaces],
);
// Separate out dormant workspaces. You cannot update a dormant workspace without
// activate it, so notify the user that these selected workspaces will not be updated.
const [dormantWorkspaces, workspacesToUpdate] = useMemo(() => {
const dormantWorkspaces = [];
const workspacesToUpdate = [];
for (const it of outdatedWorkspaces) {
if (it.dormant_at) {
dormantWorkspaces.push(it);
} else {
workspacesToUpdate.push(it);
}
}
return [dormantWorkspaces, workspacesToUpdate];
}, [outdatedWorkspaces]);
// We need to know which workspaces are running, so we can provide more detailed
// warnings about them
const runningWorkspacesToUpdate = useMemo(
() =>
workspacesToUpdate.filter(
(workspace) => workspace.latest_build.status === "running",
),
[workspacesToUpdate],
);
// If there aren't any running _and_ outdated workspaces selected, we can skip
// the consequences page, since an update shouldn't have any consequences that
// the stop didn't already. If there are dormant workspaces but no running
// workspaces, start there instead.
const [stage, setStage] = useState<
"consequences" | "dormantWorkspaces" | "updates" | null
>(null);
useEffect(() => {
if (runningWorkspacesToUpdate.length > 0) {
setStage("consequences");
} else if (dormantWorkspaces.length > 0) {
setStage("dormantWorkspaces");
} else {
setStage("updates");
}
}, [runningWorkspacesToUpdate, dormantWorkspaces, checkedWorkspaces, open]);
// Figure out which new versions everything will be updated to so that we can
// show update messages and such.
const newVersions = useMemo(() => {
const newVersions = new Map<
string,
Pick<Update, "id" | "template_display_name" | "affected_workspaces">
>();
for (const it of workspacesToUpdate) {
const versionId = it.template_active_version_id;
const version = newVersions.get(versionId);
if (version) {
version.affected_workspaces.push(it);
continue;
}
newVersions.set(versionId, {
id: versionId,
template_display_name: it.template_display_name,
affected_workspaces: [it],
});
}
return newVersions;
}, [workspacesToUpdate]);
// Not all of the information we want is included in the `Workspace` type, so we
// need to query all of the versions.
const results = useQueries({
queries: [...newVersions.values()].map((version) => ({
queryKey: ["batchUpdate", version.id],
queryFn: async () => ({
// ...but the query _also_ doesn't have everything we need, like the
// template display name!
...version,
...(await getTemplateVersion(version.id)),
}),
})),
});
const { data, error } = {
data: results.every((result) => result.isSuccess && result.data)
? results.map((result) => result.data!)
: undefined,
error: results.some((result) => result.error),
};
const onProceed = () => {
switch (stage) {
case "updates":
onConfirm();
break;
case "dormantWorkspaces":
setStage("updates");
break;
case "consequences":
setStage(
dormantWorkspaces.length > 0 ? "dormantWorkspaces" : "updates",
);
break;
}
};
const workspaceCount = `${workspacesToUpdate.length} ${
workspacesToUpdate.length === 1 ? "workspace" : "workspaces"
}`;
let confirmText: ReactNode = <>Review updates&hellip;</>;
if (stage === "updates") {
confirmText = <>Update {workspaceCount}</>;
}
return (
<ConfirmDialog
open={open}
onClose={onClose}
title={`Update ${workspaceCount}`}
hideCancel
confirmLoading={isLoading}
confirmText={confirmText}
onConfirm={onProceed}
description={
<>
{stage === "consequences" && (
<Consequences runningWorkspaces={runningWorkspacesToUpdate} />
)}
{stage === "dormantWorkspaces" && (
<DormantWorkspaces workspaces={dormantWorkspaces} />
)}
{stage === "updates" && (
<Updates
workspaces={workspacesToUpdate}
updates={data}
error={error}
/>
)}
</>
}
/>
);
};
interface ConsequencesProps {
runningWorkspaces: Workspace[];
}
const Consequences: FC<ConsequencesProps> = ({ runningWorkspaces }) => {
const workspaceCount = `${runningWorkspaces.length} ${
runningWorkspaces.length === 1 ? "running workspace" : "running workspaces"
}`;
const owners = new Set(runningWorkspaces.map((it) => it.owner_id)).size;
const ownerCount = `${owners} ${owners === 1 ? "owner" : "owners"}`;
return (
<>
<p>You are about to update {workspaceCount}.</p>
<ul css={styles.consequences}>
<li>
Updating will stop all running processes and delete non-persistent
data.
</li>
<li>
Anyone connected to a running workspace will be disconnected until the
update is complete.
</li>
<li>Any unsaved data will be lost.</li>
</ul>
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={styles.summary}
>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span>{ownerCount}</span>
</Stack>
</Stack>
</>
);
};
interface DormantWorkspacesProps {
workspaces: Workspace[];
}
const DormantWorkspaces: FC<DormantWorkspacesProps> = ({ workspaces }) => {
const mostRecent = workspaces.reduce(
(latestSoFar, against) => {
if (!latestSoFar) {
return against;
}
return new Date(against.last_used_at).getTime() >
new Date(latestSoFar.last_used_at).getTime()
? against
: latestSoFar;
},
undefined as Workspace | undefined,
);
const owners = new Set(workspaces.map((it) => it.owner_id)).size;
const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`;
return (
<>
<p>
{workspaces.length === 1 ? (
<>
This selected workspace is dormant, and must be activated before it
can be updated.
</>
) : (
<>
These selected workspaces are dormant, and must be activated before
they can be updated.
</>
)}
</p>
<ul css={styles.workspacesList}>
{workspaces.map((workspace) => (
<li key={workspace.id} css={styles.workspace}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<span css={styles.name}>{workspace.name}</span>
<Stack css={{ gap: 0, fontSize: 14, width: 128 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{workspace.owner_name}
</span>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<ScheduleIcon css={styles.summaryIcon} />
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{lastUsed(workspace.last_used_at)}
</span>
</Stack>
</Stack>
</Stack>
</li>
))}
</ul>
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={styles.summary}
>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span>{ownersCount}</span>
</Stack>
{mostRecent && (
<Stack direction="row" alignItems="center" spacing={1}>
<ScheduleIcon css={styles.summaryIcon} />
<span>Last used {lastUsed(mostRecent.last_used_at)}</span>
</Stack>
)}
</Stack>
</>
);
};
interface UpdatesProps {
workspaces: Workspace[];
updates?: Update[];
error?: unknown;
}
const Updates: FC<UpdatesProps> = ({ workspaces, updates, error }) => {
const workspaceCount = `${workspaces.length} ${
workspaces.length === 1 ? "outdated workspace" : "outdated workspaces"
}`;
const updateCount =
updates &&
`${updates.length} ${
updates.length === 1 ? "new version" : "new versions"
}`;
return (
<>
<TemplateVersionMessages updates={updates} error={error} />
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={styles.summary}
>
<Stack direction="row" alignItems="center" spacing={1}>
<InstallDesktopIcon css={styles.summaryIcon} />
<span>{workspaceCount}</span>
</Stack>
{updateCount && (
<Stack direction="row" alignItems="center" spacing={1}>
<SettingsSuggestIcon css={styles.summaryIcon} />
<span>{updateCount}</span>
</Stack>
)}
</Stack>
</>
);
};
interface TemplateVersionMessagesProps {
error?: unknown;
updates?: Update[];
}
const TemplateVersionMessages: FC<TemplateVersionMessagesProps> = ({
error,
updates,
}) => {
if (error) {
return <ErrorAlert error={error} />;
}
if (!updates) {
return <Loader />;
}
return (
<ul css={styles.updatesList}>
{updates.map((update) => (
<li key={update.id} css={styles.workspace}>
<Stack spacing={0}>
<Stack spacing={0.5} direction="row" alignItems="center">
<span css={styles.name}>{update.template_display_name}</span>
<span css={styles.newVersion}>&rarr; {update.name}</span>
</Stack>
<MemoizedInlineMarkdown
allowedElements={["ol", "ul", "li"]}
css={styles.message}
>
{update.message ?? "No message"}
</MemoizedInlineMarkdown>
<UsedBy workspaces={update.affected_workspaces} />
</Stack>
</li>
))}
</ul>
);
};
interface UsedByProps {
workspaces: Workspace[];
}
const UsedBy: FC<UsedByProps> = ({ workspaces }) => {
const workspaceNames = workspaces.map((it) => it.name);
return (
<p css={{ fontSize: 13, paddingTop: 6, lineHeight: 1.2 }}>
Used by {workspaceNames.slice(0, 2).join(", ")}{" "}
{workspaceNames.length > 2 && (
<span title={workspaceNames.slice(2).join(", ")}>
and {workspaceNames.length - 2} more
</span>
)}
</p>
);
};
const lastUsed = (time: string) => {
const now = dayjs();
const then = dayjs(time);
return then.isAfter(now.subtract(1, "hour")) ? "now" : then.fromNow();
};
const PersonIcon: FC = () => {
// This size doesn't match the rest of the icons because MUI is just really
// inconsistent. We have to make it bigger than the rest, and pull things in
// on the sides to compensate.
return <PersonOutlinedIcon css={{ width: 18, height: 18, margin: -1 }} />;
};
const styles = {
summaryIcon: { width: 16, height: 16 },
consequences: {
display: "flex",
flexDirection: "column",
gap: 8,
paddingLeft: 16,
},
workspacesList: (theme) => ({
listStyleType: "none",
padding: 0,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
overflow: "hidden auto",
maxHeight: 184,
}),
updatesList: (theme) => ({
listStyleType: "none",
padding: 0,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
overflow: "hidden auto",
maxHeight: 256,
}),
workspace: (theme) => ({
padding: "8px 16px",
borderBottom: `1px solid ${theme.palette.divider}`,
"&:last-child": {
border: "none",
},
}),
name: (theme) => ({
fontWeight: 500,
color: theme.experimental.l1.text,
}),
newVersion: (theme) => ({
fontSize: 13,
fontWeight: 500,
color: theme.experimental.roles.active.fill,
}),
message: {
fontSize: 14,
},
summary: {
gap: "6px 20px",
fontSize: 14,
},
} satisfies Record<string, Interpolation<Theme>>;
@@ -4,6 +4,10 @@ import * as CreateDayString from "utils/createDayString";
import {
MockStoppedWorkspace,
MockWorkspace,
MockDormantWorkspace,
MockDormantOutdatedWorkspace,
MockOutdatedWorkspace,
MockRunningOutdatedWorkspace,
MockWorkspacesResponse,
} from "testHelpers/entities";
import {
@@ -82,6 +86,167 @@ describe("WorkspacesPage", () => {
expect(deleteWorkspace).toHaveBeenCalledWith(workspaces[1].id);
});
describe("batch update", () => {
it("ignores up-to-date workspaces", async () => {
const workspaces = [
{ ...MockWorkspace, id: "1" }, // running, not outdated. no warning.
{ ...MockDormantWorkspace, id: "2" }, // dormant, not outdated. no warning.
{ ...MockOutdatedWorkspace, id: "3" },
{ ...MockOutdatedWorkspace, id: "4" },
];
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue({ workspaces, count: workspaces.length });
const updateWorkspace = jest.spyOn(API, "updateWorkspace");
const user = userEvent.setup();
renderWithAuth(<WorkspacesPage />);
await waitForLoaderToBeRemoved();
for (const workspace of workspaces) {
await user.click(getWorkspaceCheckbox(workspace));
}
await user.click(screen.getByRole("button", { name: /actions/i }));
const updateButton = await screen.findByText(/update/i);
await user.click(updateButton);
// One click: no running workspaces warning, no dormant workspaces warning.
// There is a running workspace and a dormant workspace selected, but they
// are not outdated.
const confirmButton = await screen.findByTestId("confirm-button");
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(/used by/i);
await user.click(confirmButton);
// `workspaces[0]` was up-to-date, and running
// `workspaces[1]` was dormant
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(2);
});
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]);
});
it("warns about and updates running workspaces", async () => {
const workspaces = [
{ ...MockRunningOutdatedWorkspace, id: "1" },
{ ...MockOutdatedWorkspace, id: "2" },
{ ...MockOutdatedWorkspace, id: "3" },
];
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue({ workspaces, count: workspaces.length });
const updateWorkspace = jest.spyOn(API, "updateWorkspace");
const user = userEvent.setup();
renderWithAuth(<WorkspacesPage />);
await waitForLoaderToBeRemoved();
for (const workspace of workspaces) {
await user.click(getWorkspaceCheckbox(workspace));
}
await user.click(screen.getByRole("button", { name: /actions/i }));
const updateButton = await screen.findByText(/update/i);
await user.click(updateButton);
// Two clicks: 1 running workspace, no dormant workspaces warning.
const confirmButton = await screen.findByTestId("confirm-button");
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(/1 running workspace/i);
await user.click(confirmButton);
expect(dialog).toHaveTextContent(/used by/i);
await user.click(confirmButton);
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(3);
});
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]);
});
it("warns about and ignores dormant workspaces", async () => {
const workspaces = [
{ ...MockDormantOutdatedWorkspace, id: "1" },
{ ...MockOutdatedWorkspace, id: "2" },
{ ...MockOutdatedWorkspace, id: "3" },
];
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue({ workspaces, count: workspaces.length });
const updateWorkspace = jest.spyOn(API, "updateWorkspace");
const user = userEvent.setup();
renderWithAuth(<WorkspacesPage />);
await waitForLoaderToBeRemoved();
for (const workspace of workspaces) {
await user.click(getWorkspaceCheckbox(workspace));
}
await user.click(screen.getByRole("button", { name: /actions/i }));
const updateButton = await screen.findByText(/update/i);
await user.click(updateButton);
// Two clicks: no running workspaces warning, 1 dormant workspace.
const confirmButton = await screen.findByTestId("confirm-button");
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(/dormant/i);
await user.click(confirmButton);
expect(dialog).toHaveTextContent(/used by/i);
await user.click(confirmButton);
// `workspaces[0]` was dormant
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(2);
});
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]);
});
it("warns about running workspaces and then dormant workspaces", async () => {
const workspaces = [
{ ...MockRunningOutdatedWorkspace, id: "1" },
{ ...MockDormantOutdatedWorkspace, id: "2" },
{ ...MockOutdatedWorkspace, id: "3" },
{ ...MockOutdatedWorkspace, id: "4" },
{ ...MockWorkspace, id: "5" },
];
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue({ workspaces, count: workspaces.length });
const updateWorkspace = jest.spyOn(API, "updateWorkspace");
const user = userEvent.setup();
renderWithAuth(<WorkspacesPage />);
await waitForLoaderToBeRemoved();
for (const workspace of workspaces) {
await user.click(getWorkspaceCheckbox(workspace));
}
await user.click(screen.getByRole("button", { name: /actions/i }));
const updateButton = await screen.findByText(/update/i);
await user.click(updateButton);
// Three clicks: 1 running workspace, 1 dormant workspace.
const confirmButton = await screen.findByTestId("confirm-button");
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(/1 running workspace/i);
await user.click(confirmButton);
expect(dialog).toHaveTextContent(/dormant/i);
await user.click(confirmButton);
expect(dialog).toHaveTextContent(/used by/i);
await user.click(confirmButton);
// `workspaces[1]` was dormant, and `workspaces[4]` was up-to-date
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(3);
});
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]);
});
});
it("stops only the running and selected workspaces", async () => {
const workspaces = [
{ ...MockWorkspace, id: "1" },
@@ -14,7 +14,9 @@ import { useUserFilterMenu } from "components/Filter/UserFilter";
import { useEffectEvent } from "hooks/hookPolyfills";
import { useQuery } from "react-query";
import { templates } from "api/queries/templates";
import { BatchDeleteConfirmation, useBatchActions } from "./BatchActions";
import { useBatchActions } from "./batchActions";
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
import { BatchUpdateConfirmation } from "./BatchUpdateConfirmation";
function useSafeSearchParams() {
// Have to wrap setSearchParams because React Router doesn't make sure that
@@ -53,7 +55,9 @@ const WorkspacesPage: FC = () => {
const updateWorkspace = useWorkspaceUpdate(queryKey);
const [checkedWorkspaces, setCheckedWorkspaces] = useState<Workspace[]>([]);
const [isConfirmingDeleteAll, setIsConfirmingDeleteAll] = useState(false);
const [confirmingBatchAction, setConfirmingBatchAction] = useState<
"delete" | "update" | null
>(null);
const [urlSearchParams] = searchParamsResult;
const { entitlements } = useDashboard();
const canCheckWorkspaces =
@@ -96,9 +100,8 @@ const WorkspacesPage: FC = () => {
updateWorkspace.mutate(workspace);
}}
isRunningBatchAction={batchActions.isLoading}
onDeleteAll={() => {
setIsConfirmingDeleteAll(true);
}}
onDeleteAll={() => setConfirmingBatchAction("delete")}
onUpdateAll={() => setConfirmingBatchAction("update")}
onStartAll={() => batchActions.startAll(checkedWorkspaces)}
onStopAll={() => batchActions.stopAll(checkedWorkspaces)}
/>
@@ -106,13 +109,26 @@ const WorkspacesPage: FC = () => {
<BatchDeleteConfirmation
isLoading={batchActions.isLoading}
checkedWorkspaces={checkedWorkspaces}
open={isConfirmingDeleteAll}
open={confirmingBatchAction === "delete"}
onConfirm={async () => {
await batchActions.deleteAll(checkedWorkspaces);
setIsConfirmingDeleteAll(false);
setConfirmingBatchAction(null);
}}
onClose={() => {
setIsConfirmingDeleteAll(false);
setConfirmingBatchAction(null);
}}
/>
<BatchUpdateConfirmation
isLoading={batchActions.isLoading}
checkedWorkspaces={checkedWorkspaces}
open={confirmingBatchAction === "update"}
onConfirm={async () => {
await batchActions.updateAll(checkedWorkspaces);
setConfirmingBatchAction(null);
}}
onClose={() => {
setConfirmingBatchAction(null);
}}
/>
</>
@@ -15,6 +15,7 @@ import { WorkspacesButton } from "./WorkspacesButton";
import { UseQueryResult } from "react-query";
import StopOutlined from "@mui/icons-material/StopOutlined";
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import CloudQueue from "@mui/icons-material/CloudQueue";
import {
MoreMenu,
MoreMenuContent,
@@ -51,6 +52,7 @@ export interface WorkspacesPageViewProps {
onCheckChange: (checkedWorkspaces: Workspace[]) => void;
isRunningBatchAction: boolean;
onDeleteAll: () => void;
onUpdateAll: () => void;
onStartAll: () => void;
onStopAll: () => void;
canCheckWorkspaces: boolean;
@@ -71,6 +73,7 @@ export const WorkspacesPageView = ({
checkedWorkspaces,
onCheckChange,
onDeleteAll,
onUpdateAll,
onStopAll,
onStartAll,
isRunningBatchAction,
@@ -150,6 +153,9 @@ export const WorkspacesPageView = ({
<StopOutlined /> Stop
</MoreMenuItem>
<Divider />
<MoreMenuItem onClick={onUpdateAll}>
<CloudQueue /> Update&hellip;
</MoreMenuItem>
<MoreMenuItem danger onClick={onDeleteAll}>
<DeleteOutlined /> Delete&hellip;
</MoreMenuItem>
@@ -195,7 +195,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
{workspace.latest_build.status === "running" &&
!workspace.health.healthy && (
<InfoTooltip
type="warning"
type="notice"
title="Workspace is unhealthy"
message="Your workspace is running but some agents are unhealthy."
/>
@@ -0,0 +1,76 @@
import { useMutation } from "react-query";
import {
deleteWorkspace,
startWorkspace,
stopWorkspace,
updateWorkspace,
} from "api/api";
import type { Workspace } from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
interface UseBatchActionsProps {
onSuccess: () => Promise<void>;
}
export function useBatchActions(options: UseBatchActionsProps) {
const { onSuccess } = options;
const startAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
return Promise.all(
workspaces.map((w) =>
startWorkspace(w.id, w.latest_build.template_version_id),
),
);
},
onSuccess,
onError: () => {
displayError("Failed to start workspaces");
},
});
const stopAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
return Promise.all(workspaces.map((w) => stopWorkspace(w.id)));
},
onSuccess,
onError: () => {
displayError("Failed to stop workspaces");
},
});
const deleteAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
return Promise.all(workspaces.map((w) => deleteWorkspace(w.id)));
},
onSuccess,
onError: () => {
displayError("Failed to delete some workspaces");
},
});
const updateAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
return Promise.all(
workspaces
.filter((w) => w.outdated && !w.dormant_at)
.map((w) => updateWorkspace(w)),
);
},
onSuccess,
onError: () => {
displayError("Failed to update some workspaces");
},
});
return {
startAll: startAllMutation.mutateAsync,
stopAll: stopAllMutation.mutateAsync,
deleteAll: deleteAllMutation.mutateAsync,
updateAll: updateAllMutation.mutateAsync,
isLoading:
startAllMutation.isLoading ||
stopAllMutation.isLoading ||
deleteAllMutation.isLoading,
};
}
+20
View File
@@ -1093,6 +1093,26 @@ export const MockOutdatedWorkspace: TypesGen.Workspace = {
outdated: true,
};
export const MockRunningOutdatedWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-running-outdated-workspace",
outdated: true,
};
export const MockDormantWorkspace: TypesGen.Workspace = {
...MockStoppedWorkspace,
id: "test-dormant-workspace",
dormant_at: new Date().toISOString(),
};
export const MockDormantOutdatedWorkspace: TypesGen.Workspace = {
...MockStoppedWorkspace,
id: "test-dormant-outdated-workspace",
name: "Dormant-Workspace",
outdated: true,
dormant_at: new Date().toISOString(),
};
export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace =
{
...MockWorkspace,
+2 -2
View File
@@ -51,10 +51,10 @@ export interface Role {
/** A border, or a color for an outlined icon */
outline: string;
/** A good color for icons, text on a neutral background, the background of a button which should stand out */
/** A color for icons, text on a neutral background, the background of a button which should stand out */
fill: string;
/** A color great for text on the corresponding `background` */
/** A color for text on the corresponding `background` */
text: string;
// contrastOutline?: string;