diff --git a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx index be2f1ee55c9fe..d39fd9526c9d1 100644 --- a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx +++ b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx @@ -66,7 +66,7 @@ const styles = { }), dialogContent: (theme) => ({ color: theme.palette.text.secondary, - padding: 40, + padding: "40px 40px 20px", }), dialogTitle: (theme) => ({ margin: 0, diff --git a/site/src/components/Dialogs/Dialog.tsx b/site/src/components/Dialogs/Dialog.tsx index 04cb5411197e5..f526fa394d499 100644 --- a/site/src/components/Dialogs/Dialog.tsx +++ b/site/src/components/Dialogs/Dialog.tsx @@ -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": { diff --git a/site/src/components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx b/site/src/components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx index dc44b466068a5..6e3f416eeb4da 100644 --- a/site/src/components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx +++ b/site/src/components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx @@ -47,9 +47,13 @@ export const WorkspaceOutdatedTooltip: FC = (props) => { ); }; -export const WorkspaceOutdatedTooltipContent = (props: TooltipProps) => { +export const WorkspaceOutdatedTooltipContent: FC = ({ + onUpdateVersion, + ariaLabel, + latestVersionId, + templateName, +}) => { const popover = usePopover(); - const { onUpdateVersion, ariaLabel, latestVersionId, templateName } = props; const { data: activeVersion } = useQuery({ ...templateVersion(latestVersionId), enabled: popover.isOpen, diff --git a/site/src/pages/UserSettingsPage/TokensPage/ConfirmDeleteDialog.stories.tsx b/site/src/pages/UserSettingsPage/TokensPage/ConfirmDeleteDialog.stories.tsx index e2062fe0aad34..35cb2193d7876 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/ConfirmDeleteDialog.stories.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/ConfirmDeleteDialog.stories.tsx @@ -32,8 +32,6 @@ export const DeleteDialog: Story = { args: { queryKey: ["tokens"], token: MockToken, - setToken: () => { - return null; - }, + setToken: () => null, }, }; diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 6d45c24b20d4e..73ff4b623b508 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -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 = ({ workspace, template, permissions, -}: WorkspaceReadyPageProps): JSX.Element => { +}) => { const navigate = useNavigate(); const queryClient = useQueryClient(); const { buildInfo } = useDashboard(); diff --git a/site/src/pages/WorkspacesPage/BatchDelete.stories.tsx b/site/src/pages/WorkspacesPage/BatchDeleteConfirmation.stories.tsx similarity index 78% rename from site/src/pages/WorkspacesPage/BatchDelete.stories.tsx rename to site/src/pages/WorkspacesPage/BatchDeleteConfirmation.stories.tsx index cd5f89762de2c..b52a15ac6e805 100644 --- a/site/src/pages/WorkspacesPage/BatchDelete.stories.tsx +++ b/site/src/pages/WorkspacesPage/BatchDeleteConfirmation.stories.tsx @@ -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 = { - title: "pages/WorkspacesPage/BatchDelete", + title: "pages/WorkspacesPage/BatchDeleteConfirmation", + parameters: { chromatic }, component: BatchDeleteConfirmation, args: { onClose: action("onClose"), @@ -35,4 +37,4 @@ type Story = StoryObj; const Example: Story = {}; -export { Example as BatchDelete }; +export { Example as BatchDeleteConfirmation }; diff --git a/site/src/pages/WorkspacesPage/BatchActions.tsx b/site/src/pages/WorkspacesPage/BatchDeleteConfirmation.tsx similarity index 81% rename from site/src/pages/WorkspacesPage/BatchActions.tsx rename to site/src/pages/WorkspacesPage/BatchDeleteConfirmation.tsx index e8ee5898a66f4..b735326cc0e44 100644 --- a/site/src/pages/WorkspacesPage/BatchActions.tsx +++ b/site/src/pages/WorkspacesPage/BatchDeleteConfirmation.tsx @@ -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; -} - -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 = ({ workspaces }) => { + const theme = useTheme(); + const mostRecent = workspaces.reduce( (latestSoFar, against) => { if (!latestSoFar) { @@ -209,7 +158,9 @@ const Workspaces: FC = ({ workspaces }) => { alignItems="center" justifyContent="space-between" > - + {workspace.name} @@ -234,7 +185,12 @@ const Workspaces: FC = ({ workspaces }) => { ))} - + {ownersCount} diff --git a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx new file mode 100644 index 0000000000000..b9a986150818f --- /dev/null +++ b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx @@ -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(); +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 = { + 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 ; + }, + ], + args: { + onClose: action("onClose"), + onConfirm: action("onConfirm"), + open: true, + checkedWorkspaces: workspaces, + }, +}; + +export default meta; +type Story = StoryObj; + +const Example: Story = {}; + +export { Example as BatchUpdateConfirmation }; diff --git a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx new file mode 100644 index 0000000000000..fe2b514d90556 --- /dev/null +++ b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx @@ -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 = ({ + 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 + >(); + + 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…; + if (stage === "updates") { + confirmText = <>Update {workspaceCount}; + } + + return ( + + {stage === "consequences" && ( + + )} + {stage === "dormantWorkspaces" && ( + + )} + {stage === "updates" && ( + + )} + + } + /> + ); +}; + +interface ConsequencesProps { + runningWorkspaces: Workspace[]; +} + +const Consequences: FC = ({ 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 ( + <> +

You are about to update {workspaceCount}.

+
    +
  • + Updating will stop all running processes and delete non-persistent + data. +
  • +
  • + Anyone connected to a running workspace will be disconnected until the + update is complete. +
  • +
  • Any unsaved data will be lost.
  • +
+ + + + {ownerCount} + + + + ); +}; + +interface DormantWorkspacesProps { + workspaces: Workspace[]; +} + +const DormantWorkspaces: FC = ({ 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 ( + <> +

+ {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. + + )} +

+
    + {workspaces.map((workspace) => ( +
  • + + {workspace.name} + + + + + {workspace.owner_name} + + + + + + {lastUsed(workspace.last_used_at)} + + + + +
  • + ))} +
+ + + + {ownersCount} + + {mostRecent && ( + + + Last used {lastUsed(mostRecent.last_used_at)} + + )} + + + ); +}; + +interface UpdatesProps { + workspaces: Workspace[]; + updates?: Update[]; + error?: unknown; +} + +const Updates: FC = ({ 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 ( + <> + + + + + {workspaceCount} + + {updateCount && ( + + + {updateCount} + + )} + + + ); +}; + +interface TemplateVersionMessagesProps { + error?: unknown; + updates?: Update[]; +} + +const TemplateVersionMessages: FC = ({ + error, + updates, +}) => { + if (error) { + return ; + } + + if (!updates) { + return ; + } + + return ( +
    + {updates.map((update) => ( +
  • + + + {update.template_display_name} + → {update.name} + + + {update.message ?? "No message"} + + + +
  • + ))} +
+ ); +}; + +interface UsedByProps { + workspaces: Workspace[]; +} + +const UsedBy: FC = ({ workspaces }) => { + const workspaceNames = workspaces.map((it) => it.name); + + return ( +

+ Used by {workspaceNames.slice(0, 2).join(", ")}{" "} + {workspaceNames.length > 2 && ( + + and {workspaceNames.length - 2} more + + )} +

+ ); +}; + +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 ; +}; + +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>; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 0bc0ca1ec1233..16afb576f85e0 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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" }, diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 9bd2e38d14fcc..303ead72dfb4d 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -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([]); - 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 = () => { { await batchActions.deleteAll(checkedWorkspaces); - setIsConfirmingDeleteAll(false); + setConfirmingBatchAction(null); + }} + onClose={() => { + setConfirmingBatchAction(null); + }} + /> + + { + await batchActions.updateAll(checkedWorkspaces); + setConfirmingBatchAction(null); }} onClose={() => { - setIsConfirmingDeleteAll(false); + setConfirmingBatchAction(null); }} /> diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 4fc999965ba76..161efee6cc367 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -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 = ({ Stop + + Update… + Delete… diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 9a32bf5af3607..3afdc8b257e30 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -195,7 +195,7 @@ export const WorkspacesTable: FC = ({ {workspace.latest_build.status === "running" && !workspace.health.healthy && ( diff --git a/site/src/pages/WorkspacesPage/batchActions.tsx b/site/src/pages/WorkspacesPage/batchActions.tsx new file mode 100644 index 0000000000000..1aa2fdf281791 --- /dev/null +++ b/site/src/pages/WorkspacesPage/batchActions.tsx @@ -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; +} + +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, + }; +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 65be658bad575..f759363b293b3 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1089,6 +1089,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, diff --git a/site/src/theme/experimental.ts b/site/src/theme/experimental.ts index bd8942a43ce36..a26d4cc0cb739 100644 --- a/site/src/theme/experimental.ts +++ b/site/src/theme/experimental.ts @@ -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; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy