From 07cdbc47c3e66fa9c42985c2f8eff63eb2600e31 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 11 Jan 2024 20:10:13 +0000 Subject: [PATCH 01/12] feat: batch workspace updates --- .../src/pages/WorkspacesPage/BatchActions.tsx | 20 +++++++++++++++++-- .../pages/WorkspacesPage/WorkspacesPage.tsx | 1 + .../WorkspacesPage/WorkspacesPageView.tsx | 6 ++++++ site/src/theme/light/mui.ts | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/site/src/pages/WorkspacesPage/BatchActions.tsx b/site/src/pages/WorkspacesPage/BatchActions.tsx index e8ee5898a66f4..c7cdc588bbc6b 100644 --- a/site/src/pages/WorkspacesPage/BatchActions.tsx +++ b/site/src/pages/WorkspacesPage/BatchActions.tsx @@ -6,7 +6,12 @@ import "dayjs/plugin/relativeTime"; import { 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 { + deleteWorkspace, + startWorkspace, + stopWorkspace, + updateWorkspace, +} from "api/api"; import type { Workspace } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { displayError } from "components/GlobalSnackbar/utils"; @@ -50,7 +55,17 @@ export function useBatchActions(options: UseBatchActionsProps) { }, onSuccess, onError: () => { - displayError("Failed to delete workspaces"); + displayError("Failed to delete some workspaces"); + }, + }); + + const updateAllMutation = useMutation({ + mutationFn: async (workspaces: Workspace[]) => { + return Promise.all(workspaces.map((w) => updateWorkspace(w))); + }, + onSuccess, + onError: () => { + displayError("Failed to update some workspaces"); }, }); @@ -58,6 +73,7 @@ export function useBatchActions(options: UseBatchActionsProps) { startAll: startAllMutation.mutateAsync, stopAll: stopAllMutation.mutateAsync, deleteAll: deleteAllMutation.mutateAsync, + updateAll: updateAllMutation.mutateAsync, isLoading: startAllMutation.isLoading || stopAllMutation.isLoading || diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 9bd2e38d14fcc..4ef8be1cbc245 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -99,6 +99,7 @@ const WorkspacesPage: FC = () => { onDeleteAll={() => { setIsConfirmingDeleteAll(true); }} + onUpdateAll={() => batchActions.updateAll(checkedWorkspaces)} onStartAll={() => batchActions.startAll(checkedWorkspaces)} onStopAll={() => batchActions.stopAll(checkedWorkspaces)} /> 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/theme/light/mui.ts b/site/src/theme/light/mui.ts index e508dabc3c789..80f117aa369a4 100644 --- a/site/src/theme/light/mui.ts +++ b/site/src/theme/light/mui.ts @@ -10,7 +10,7 @@ const muiTheme = createTheme({ mode: "light", primary: { main: tw.sky[600], - contrastText: tw.sky[50], + contrastText: tw.sky[950], light: tw.sky[400], dark: tw.sky[500], }, From 3273e75f590d8148b8d86602b5dfedf5262ecef9 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 12 Jan 2024 22:45:20 +0000 Subject: [PATCH 02/12] =?UTF-8?q?=F0=9F=A7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/components/Dialogs/Dialog.tsx | 4 +- .../WorkspaceOutdatedTooltip.tsx | 8 +- .../ConfirmDeleteDialog.stories.tsx | 4 +- ...sx => BatchDeleteConfirmation.stories.tsx} | 8 +- ...ctions.tsx => BatchDeleteConfirmation.tsx} | 90 ++------ .../BatchUpdateConfirmation.stories.tsx | 40 ++++ .../BatchUpdateConfirmation.tsx | 192 ++++++++++++++++++ .../pages/WorkspacesPage/WorkspacesPage.tsx | 24 ++- .../src/pages/WorkspacesPage/batchActions.tsx | 78 +++++++ site/src/theme/light/experimental.ts | 4 +- 10 files changed, 360 insertions(+), 92 deletions(-) rename site/src/pages/WorkspacesPage/{BatchDelete.stories.tsx => BatchDeleteConfirmation.stories.tsx} (78%) rename site/src/pages/WorkspacesPage/{BatchActions.tsx => BatchDeleteConfirmation.tsx} (78%) create mode 100644 site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx create mode 100644 site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx create mode 100644 site/src/pages/WorkspacesPage/batchActions.tsx 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/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 78% rename from site/src/pages/WorkspacesPage/BatchActions.tsx rename to site/src/pages/WorkspacesPage/BatchDeleteConfirmation.tsx index c7cdc588bbc6b..b735326cc0e44 100644 --- a/site/src/pages/WorkspacesPage/BatchActions.tsx +++ b/site/src/pages/WorkspacesPage/BatchDeleteConfirmation.tsx @@ -2,84 +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, - updateWorkspace, -} 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 some workspaces"); - }, - }); - - const updateAllMutation = useMutation({ - mutationFn: async (workspaces: Workspace[]) => { - return Promise.all(workspaces.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, - }; -} +dayjs.extend(relativeTime); type BatchDeleteConfirmationProps = { checkedWorkspaces: Workspace[]; @@ -198,6 +129,8 @@ const Consequences: FC = () => { }; const Workspaces: FC = ({ workspaces }) => { + const theme = useTheme(); + const mostRecent = workspaces.reduce( (latestSoFar, against) => { if (!latestSoFar) { @@ -225,7 +158,9 @@ const Workspaces: FC = ({ workspaces }) => { alignItems="center" justifyContent="space-between" > - + {workspace.name} @@ -250,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..d72e67aaa54f5 --- /dev/null +++ b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx @@ -0,0 +1,40 @@ +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 { BatchUpdateConfirmation } from "./BatchUpdateConfirmation"; + +const meta: Meta = { + title: "pages/WorkspacesPage/BatchUpdateConfirmation", + parameters: { chromatic }, + component: BatchUpdateConfirmation, + args: { + onClose: action("onClose"), + onConfirm: action("onConfirm"), + open: true, + checkedWorkspaces: [ + MockWorkspace, + { + ...MockWorkspace, + name: "Test-Workspace-2", + last_used_at: "2023-08-16T15:29:10.302441433Z", + owner_id: MockUser2.id, + owner_name: MockUser2.username, + }, + { + ...MockWorkspace, + name: "Test-Workspace-3", + last_used_at: "2023-11-16T15:29:10.302441433Z", + owner_id: MockUser2.id, + owner_name: MockUser2.username, + }, + ], + }, +}; + +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..89288686417b0 --- /dev/null +++ b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx @@ -0,0 +1,192 @@ +import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined"; +import ScheduleIcon from "@mui/icons-material/Schedule"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useTheme, type Interpolation, type Theme } from "@emotion/react"; +import { type FC } from "react"; +import { useQuery } 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 const BatchUpdateConfirmation: FC = ({ + checkedWorkspaces, + open, + onClose, + onConfirm, + isLoading, +}) => { + const workspaceCount = `${checkedWorkspaces.length} ${ + checkedWorkspaces.length === 1 ? "workspace" : "workspaces" + }`; + + return ( + Update {workspaceCount}} + onConfirm={onConfirm} + description={} + /> + ); +}; + +const Workspaces: FC<{ workspaces: Workspace[] }> = ({ 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 workspacesCount = `${workspaces.length} ${ + workspaces.length === 1 ? "workspace" : "workspaces" + }`; + + const newTemplateVersions = new Map( + workspaces.map((it) => [ + it.template_active_version_id, + it.template_display_name, + ]), + ); + const templatesCount = `${newTemplateVersions.size} ${ + newTemplateVersions.size === 1 ? "template" : "templates" + }`; + + const { data, error } = useQuery({ + queryFn: () => + Promise.all( + [...newTemplateVersions].map( + async ([id, name]) => [name, await getTemplateVersion(id)] as const, + ), + ), + }); + + return ( + <> + + + + + {workspacesCount} + + + + {templatesCount} + + {mostRecent && ( + + + Last used {dayjs(mostRecent.last_used_at).fromNow()} + + )} + + + ); +}; + +interface TemplateVersionMessagesProps { + error?: unknown; + templateVersions?: Array; +} + +const TemplateVersionMessages: FC = ({ + error, + templateVersions, +}) => { + const theme = useTheme(); + + if (error) { + return ; + } + + if (!templateVersions) { + return ; + } + + return ( +
    + {templateVersions.map(([templateName, version]) => ( +
  • + + + {templateName} ({version.name}) + + {version.message ?? "No message"} + +
  • + ))} +
+ ); +}; + +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, + marginBottom: 0, + }, + + workspacesList: (theme) => ({ + listStyleType: "none", + padding: 0, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 8, + overflow: "hidden auto", + maxHeight: 184, + }), + + workspace: (theme) => ({ + padding: "8px 16px", + borderBottom: `1px solid ${theme.palette.divider}`, + + "&:last-child": { + border: "none", + }, + }), + + message: { + fontSize: 13, + }, +} satisfies Record>; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 4ef8be1cbc245..6cc028dd42a12 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 @@ -54,6 +56,7 @@ const WorkspacesPage: FC = () => { const updateWorkspace = useWorkspaceUpdate(queryKey); const [checkedWorkspaces, setCheckedWorkspaces] = useState([]); const [isConfirmingDeleteAll, setIsConfirmingDeleteAll] = useState(false); + const [isConfirmingUpdateAll, setIsConfirmingUpdateAll] = useState(false); const [urlSearchParams] = searchParamsResult; const { entitlements } = useDashboard(); const canCheckWorkspaces = @@ -96,10 +99,8 @@ const WorkspacesPage: FC = () => { updateWorkspace.mutate(workspace); }} isRunningBatchAction={batchActions.isLoading} - onDeleteAll={() => { - setIsConfirmingDeleteAll(true); - }} - onUpdateAll={() => batchActions.updateAll(checkedWorkspaces)} + onDeleteAll={() => setIsConfirmingDeleteAll(true)} + onUpdateAll={() => setIsConfirmingUpdateAll(true)} onStartAll={() => batchActions.startAll(checkedWorkspaces)} onStopAll={() => batchActions.stopAll(checkedWorkspaces)} /> @@ -116,6 +117,19 @@ const WorkspacesPage: FC = () => { setIsConfirmingDeleteAll(false); }} /> + + { + await batchActions.updateAll(checkedWorkspaces); + setIsConfirmingUpdateAll(false); + }} + onClose={() => { + setIsConfirmingUpdateAll(false); + }} + /> ); }; diff --git a/site/src/pages/WorkspacesPage/batchActions.tsx b/site/src/pages/WorkspacesPage/batchActions.tsx new file mode 100644 index 0000000000000..4862fa96c112d --- /dev/null +++ b/site/src/pages/WorkspacesPage/batchActions.tsx @@ -0,0 +1,78 @@ +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: 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 some workspaces"); + }, + }); + + const updateAllMutation = useMutation({ + mutationFn: async (workspaces: Workspace[]) => { + return Promise.all( + workspaces + .filter((w) => w.outdated) + .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/theme/light/experimental.ts b/site/src/theme/light/experimental.ts index e8ad80d905b78..7c0f0d0d536d5 100644 --- a/site/src/theme/light/experimental.ts +++ b/site/src/theme/light/experimental.ts @@ -32,7 +32,7 @@ export default { danger: { background: colors.orange[50], outline: colors.orange[400], - fill: colors.orange[600], + fill: colors.orange[500], text: colors.orange[950], disabled: { background: colors.orange[50], @@ -43,7 +43,7 @@ export default { hover: { background: colors.orange[100], outline: colors.orange[500], - fill: colors.orange[500], + fill: colors.orange[400], text: colors.black, }, }, From 4400e7027c6bf8f40c981f1a28358a07a6d665b2 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 16 Jan 2024 23:06:45 +0000 Subject: [PATCH 03/12] =?UTF-8?q?=F0=9F=A7=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dialogs/ConfirmDialog/ConfirmDialog.tsx | 2 +- site/src/components/Dialogs/Dialog.tsx | 6 +- .../WorkspacePage/WorkspaceReadyPage.tsx | 6 +- .../BatchUpdateConfirmation.tsx | 397 +++++++++++++++--- .../src/pages/WorkspacesPage/batchActions.tsx | 1 + site/src/theme/light/experimental.ts | 2 +- site/src/theme/light/mui.ts | 6 +- 7 files changed, 361 insertions(+), 59 deletions(-) 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 f526fa394d499..370619e32c3dd 100644 --- a/site/src/components/Dialogs/Dialog.tsx +++ b/site/src/components/Dialogs/Dialog.tsx @@ -96,14 +96,14 @@ const styles = { }), successButton: (theme) => ({ "&.MuiButton-contained": { - backgroundColor: theme.palette.success.dark, + backgroundColor: theme.experimental.roles.active.fill, "&:not(.MuiLoadingButton-loading)": { - color: theme.palette.primary.contrastText, + color: theme.experimental.roles.active.text, }, "&:hover": { - backgroundColor: theme.palette.success.main, + backgroundColor: theme.experimental.roles.active.hover.fill, "@media (hover: none)": { backgroundColor: "transparent", 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/BatchUpdateConfirmation.tsx b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx index 89288686417b0..b0560dd91aca3 100644 --- a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx +++ b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx @@ -2,8 +2,15 @@ import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined"; import ScheduleIcon from "@mui/icons-material/Schedule"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; -import { useTheme, type Interpolation, type Theme } from "@emotion/react"; -import { type FC } from "react"; +import { type Interpolation, type Theme } from "@emotion/react"; +import { + type FC, + type ReactNode, + useId, + useMemo, + useState, + useEffect, +} from "react"; import { useQuery } from "react-query"; import { getTemplateVersion } from "api/api"; import type { TemplateVersion, Workspace } from "api/typesGenerated"; @@ -23,6 +30,11 @@ type BatchUpdateConfirmationProps = { onConfirm: () => void; }; +interface Update extends TemplateVersion { + template_display_name: string; + affected_workspaces: Workspace[]; +} + export const BatchUpdateConfirmation: FC = ({ checkedWorkspaces, open, @@ -30,26 +42,191 @@ export const BatchUpdateConfirmation: FC = ({ onConfirm, isLoading, }) => { - const workspaceCount = `${checkedWorkspaces.length} ${ - checkedWorkspaces.length === 1 ? "workspace" : "workspaces" + // 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) { + dormantWorkspaces.push(it); + if (it.dormant_at) { + } 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"); + } + }, [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(it.template_active_version_id, { + id: it.template_active_version_id, + 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 queryId = useId(); + const { data, error } = useQuery({ + queryKey: ["batchUpdate", queryId], + queryFn: () => + Promise.all( + [...newVersions.values()].map(async (version) => ({ + // ...but the query _also_ doesn't have everything we need, like the + // template display name! + ...version, + ...(await getTemplateVersion(version.id)), + })), + ), + enabled: open, + }); + + 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 ( Update {workspaceCount}} - onConfirm={onConfirm} - description={} + confirmText={confirmText} + onConfirm={onProceed} + description={ + <> + {stage === "consequences" && ( + + )} + {stage === "dormantWorkspaces" && ( + + )} + {stage === "updates" && ( + + )} + + } /> ); }; -const Workspaces: FC<{ workspaces: Workspace[] }> = ({ workspaces }) => { +interface ConsequencesProps { + runningWorkspaceCount: number; +} + +const Consequences: FC = ({ runningWorkspaceCount }) => { + return ( + <> +

+ You are about to update{" "} + {runningWorkspaceCount === 1 + ? "a running workspace" + : "multiple running workspaces"} + . +

+
    +
  • + 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.
  • +
+ + ); +}; + +interface DormantWorkspacesProps { + workspaces: Workspace[]; +} + +const DormantWorkspaces: FC = ({ workspaces }) => { const mostRecent = workspaces.reduce( (latestSoFar, against) => { if (!latestSoFar) { @@ -64,50 +241,119 @@ const Workspaces: FC<{ workspaces: Workspace[] }> = ({ workspaces }) => { undefined as Workspace | undefined, ); - const workspacesCount = `${workspaces.length} ${ - workspaces.length === 1 ? "workspace" : "workspaces" - }`; + const owners = new Set(workspaces.map((it) => it.owner_id)).size; + const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`; - const newTemplateVersions = new Map( - workspaces.map((it) => [ - it.template_active_version_id, - it.template_display_name, - ]), + return ( + <> +

+ 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 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 templatesCount = `${newTemplateVersions.size} ${ - newTemplateVersions.size === 1 ? "template" : "templates" + + const workspaceCount = `${workspaces.length} ${ + workspaces.length === 1 ? "outdated workspace" : "outdated workspaces" }`; - const { data, error } = useQuery({ - queryFn: () => - Promise.all( - [...newTemplateVersions].map( - async ([id, name]) => [name, await getTemplateVersion(id)] as const, - ), - ), - }); + const updateCount = + updates && + `${updates.length} ${updates.length === 1 ? "template" : "templates"}`; return ( <> - + - {workspacesCount} - - - - {templatesCount} + {workspaceCount} + {updateCount && ( + + + {updateCount} + + )} {mostRecent && ( - Last used {dayjs(mostRecent.last_used_at).fromNow()} + Last used {lastUsed(mostRecent.last_used_at)} )} @@ -117,32 +363,37 @@ const Workspaces: FC<{ workspaces: Workspace[] }> = ({ workspaces }) => { interface TemplateVersionMessagesProps { error?: unknown; - templateVersions?: Array; + updates?: Update[]; } const TemplateVersionMessages: FC = ({ error, - templateVersions, + updates, }) => { - const theme = useTheme(); - if (error) { return ; } - if (!templateVersions) { + if (!updates) { return ; } return ( -
    - {templateVersions.map(([templateName, version]) => ( -
  • +
      + {updates.map((update) => ( +
    • - - {templateName} ({version.name}) - - {version.message ?? "No message"} + + {update.template_display_name} + → {update.name} + + + {update.message ?? "No message"} + +
    • ))} @@ -150,6 +401,31 @@ const TemplateVersionMessages: FC = ({ ); }; +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 @@ -177,6 +453,15 @@ const styles = { 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}`, @@ -186,7 +471,23 @@ const styles = { }, }), - message: { + 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/batchActions.tsx b/site/src/pages/WorkspacesPage/batchActions.tsx index 4862fa96c112d..60e54c5e18070 100644 --- a/site/src/pages/WorkspacesPage/batchActions.tsx +++ b/site/src/pages/WorkspacesPage/batchActions.tsx @@ -54,6 +54,7 @@ export function useBatchActions(options: UseBatchActionsProps) { return Promise.all( workspaces .filter((w) => w.outdated) + .filter((w) => (console.log(w.dormant_at), true)) .map((w) => { updateWorkspace(w); }), diff --git a/site/src/theme/light/experimental.ts b/site/src/theme/light/experimental.ts index 7c0f0d0d536d5..5741bd6ebadad 100644 --- a/site/src/theme/light/experimental.ts +++ b/site/src/theme/light/experimental.ts @@ -92,7 +92,7 @@ export default { active: { background: colors.sky[100], outline: colors.sky[500], - fill: colors.sky[600], + fill: colors.sky[500], text: colors.sky[950], disabled: { background: colors.sky[50], diff --git a/site/src/theme/light/mui.ts b/site/src/theme/light/mui.ts index 80f117aa369a4..08ec1a02c3fe0 100644 --- a/site/src/theme/light/mui.ts +++ b/site/src/theme/light/mui.ts @@ -9,10 +9,10 @@ const muiTheme = createTheme({ palette: { mode: "light", primary: { - main: tw.sky[600], + main: tw.sky[500], contrastText: tw.sky[950], - light: tw.sky[400], - dark: tw.sky[500], + light: tw.sky[300], + dark: tw.sky[400], }, secondary: { main: tw.zinc[500], From 908a46c24e3224d2feb4e8c808f805f9604638de Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 16 Jan 2024 23:45:58 +0000 Subject: [PATCH 04/12] fix icons and small things --- .../BatchUpdateConfirmation.tsx | 67 +++++++++---------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx index b0560dd91aca3..db75dfa8f5f52 100644 --- a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx +++ b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx @@ -1,5 +1,7 @@ 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"; @@ -55,8 +57,8 @@ export const BatchUpdateConfirmation: FC = ({ const workspacesToUpdate = []; for (const it of outdatedWorkspaces) { - dormantWorkspaces.push(it); if (it.dormant_at) { + dormantWorkspaces.push(it); } else { workspacesToUpdate.push(it); } @@ -173,9 +175,7 @@ export const BatchUpdateConfirmation: FC = ({ description={ <> {stage === "consequences" && ( - + )} {stage === "dormantWorkspaces" && ( @@ -194,19 +194,20 @@ export const BatchUpdateConfirmation: FC = ({ }; interface ConsequencesProps { - runningWorkspaceCount: number; + runningWorkspaces: Workspace[]; } -const Consequences: FC = ({ runningWorkspaceCount }) => { +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{" "} - {runningWorkspaceCount === 1 - ? "a running workspace" - : "multiple running workspaces"} - . -

      +

      You are about to update {workspaceCount}.

      • Updating will stop all running processes and delete non-persistent @@ -218,6 +219,17 @@ const Consequences: FC = ({ runningWorkspaceCount }) => {
      • Any unsaved data will be lost.
      + + + + {ownerCount} + + ); }; @@ -309,27 +321,15 @@ interface UpdatesProps { } const Updates: FC = ({ workspaces, updates, error }) => { - 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 workspaceCount = `${workspaces.length} ${ workspaces.length === 1 ? "outdated workspace" : "outdated workspaces" }`; const updateCount = updates && - `${updates.length} ${updates.length === 1 ? "template" : "templates"}`; + `${updates.length} ${ + updates.length === 1 ? "new version" : "new versions" + }`; return ( <> @@ -341,21 +341,15 @@ const Updates: FC = ({ workspaces, updates, error }) => { css={styles.summary} > - + {workspaceCount} {updateCount && ( - + {updateCount} )} - {mostRecent && ( - - - Last used {lastUsed(mostRecent.last_used_at)} - - )} ); @@ -441,7 +435,6 @@ const styles = { flexDirection: "column", gap: 8, paddingLeft: 16, - marginBottom: 0, }, workspacesList: (theme) => ({ From 354d3fc6edf58e2bf10535a657dfb7f9f3a25fe3 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 16 Jan 2024 23:57:15 +0000 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/pages/WorkspacesPage/batchActions.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/site/src/pages/WorkspacesPage/batchActions.tsx b/site/src/pages/WorkspacesPage/batchActions.tsx index 60e54c5e18070..1aa2fdf281791 100644 --- a/site/src/pages/WorkspacesPage/batchActions.tsx +++ b/site/src/pages/WorkspacesPage/batchActions.tsx @@ -16,7 +16,7 @@ export function useBatchActions(options: UseBatchActionsProps) { const { onSuccess } = options; const startAllMutation = useMutation({ - mutationFn: async (workspaces: Workspace[]) => { + mutationFn: (workspaces: Workspace[]) => { return Promise.all( workspaces.map((w) => startWorkspace(w.id, w.latest_build.template_version_id), @@ -30,7 +30,7 @@ export function useBatchActions(options: UseBatchActionsProps) { }); const stopAllMutation = useMutation({ - mutationFn: async (workspaces: Workspace[]) => { + mutationFn: (workspaces: Workspace[]) => { return Promise.all(workspaces.map((w) => stopWorkspace(w.id))); }, onSuccess, @@ -40,7 +40,7 @@ export function useBatchActions(options: UseBatchActionsProps) { }); const deleteAllMutation = useMutation({ - mutationFn: async (workspaces: Workspace[]) => { + mutationFn: (workspaces: Workspace[]) => { return Promise.all(workspaces.map((w) => deleteWorkspace(w.id))); }, onSuccess, @@ -50,14 +50,11 @@ export function useBatchActions(options: UseBatchActionsProps) { }); const updateAllMutation = useMutation({ - mutationFn: async (workspaces: Workspace[]) => { + mutationFn: (workspaces: Workspace[]) => { return Promise.all( workspaces - .filter((w) => w.outdated) - .filter((w) => (console.log(w.dormant_at), true)) - .map((w) => { - updateWorkspace(w); - }), + .filter((w) => w.outdated && !w.dormant_at) + .map((w) => updateWorkspace(w)), ); }, onSuccess, From 8ce568d7b58d7d3942297246b5d8e48184ebcb0c Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 17 Jan 2024 00:08:10 +0000 Subject: [PATCH 06/12] :| --- site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx index db75dfa8f5f52..6d1cd6160d3e8 100644 --- a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx +++ b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx @@ -92,7 +92,7 @@ export const BatchUpdateConfirmation: FC = ({ } else { setStage("updates"); } - }, [checkedWorkspaces, open]); + }, [runningWorkspacesToUpdate, dormantWorkspaces, checkedWorkspaces, open]); // Figure out which new versions everything will be updated to so that we can // show update messages and such. From a5307cbb1c96b14bbc6ac66e9192bf561e21f2e1 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 17 Jan 2024 17:56:19 +0000 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=8C=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/WorkspacesPage/WorkspacesPage.tsx | 21 ++++++++++--------- .../pages/WorkspacesPage/WorkspacesTable.tsx | 2 +- site/src/theme/light/experimental.ts | 2 +- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 6cc028dd42a12..303ead72dfb4d 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -55,8 +55,9 @@ const WorkspacesPage: FC = () => { const updateWorkspace = useWorkspaceUpdate(queryKey); const [checkedWorkspaces, setCheckedWorkspaces] = useState([]); - const [isConfirmingDeleteAll, setIsConfirmingDeleteAll] = useState(false); - const [isConfirmingUpdateAll, setIsConfirmingUpdateAll] = useState(false); + const [confirmingBatchAction, setConfirmingBatchAction] = useState< + "delete" | "update" | null + >(null); const [urlSearchParams] = searchParamsResult; const { entitlements } = useDashboard(); const canCheckWorkspaces = @@ -99,8 +100,8 @@ const WorkspacesPage: FC = () => { updateWorkspace.mutate(workspace); }} isRunningBatchAction={batchActions.isLoading} - onDeleteAll={() => setIsConfirmingDeleteAll(true)} - onUpdateAll={() => setIsConfirmingUpdateAll(true)} + onDeleteAll={() => setConfirmingBatchAction("delete")} + onUpdateAll={() => setConfirmingBatchAction("update")} onStartAll={() => batchActions.startAll(checkedWorkspaces)} onStopAll={() => batchActions.stopAll(checkedWorkspaces)} /> @@ -108,26 +109,26 @@ const WorkspacesPage: FC = () => { { await batchActions.deleteAll(checkedWorkspaces); - setIsConfirmingDeleteAll(false); + setConfirmingBatchAction(null); }} onClose={() => { - setIsConfirmingDeleteAll(false); + setConfirmingBatchAction(null); }} /> { await batchActions.updateAll(checkedWorkspaces); - setIsConfirmingUpdateAll(false); + setConfirmingBatchAction(null); }} onClose={() => { - setIsConfirmingUpdateAll(false); + setConfirmingBatchAction(null); }} /> 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/theme/light/experimental.ts b/site/src/theme/light/experimental.ts index 5741bd6ebadad..b5b6b0f42ae9b 100644 --- a/site/src/theme/light/experimental.ts +++ b/site/src/theme/light/experimental.ts @@ -55,7 +55,7 @@ export default { }, warning: { background: colors.amber[50], - outline: colors.amber[300], + outline: colors.amber[400], fill: colors.amber[500], text: colors.amber[950], }, From a400d93a13f43a243f74eb6f832568116744e225 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 17 Jan 2024 19:27:44 +0000 Subject: [PATCH 08/12] =?UTF-8?q?=F0=9F=A7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspacesPage/WorkspacesPage.test.tsx | 165 ++++++++++++++++++ site/src/testHelpers/entities.ts | 19 ++ 2 files changed, 184 insertions(+) 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/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 65be658bad575..ff84ddff76362 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1089,6 +1089,25 @@ 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", + outdated: true, + dormant_at: new Date().toISOString(), +}; + export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = { ...MockWorkspace, From 73c2f9be09bf14b76274a45ceb34641c395d9841 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 17 Jan 2024 20:37:00 +0000 Subject: [PATCH 09/12] later --- site/src/theme/light/experimental.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/theme/light/experimental.ts b/site/src/theme/light/experimental.ts index b5b6b0f42ae9b..e8ad80d905b78 100644 --- a/site/src/theme/light/experimental.ts +++ b/site/src/theme/light/experimental.ts @@ -32,7 +32,7 @@ export default { danger: { background: colors.orange[50], outline: colors.orange[400], - fill: colors.orange[500], + fill: colors.orange[600], text: colors.orange[950], disabled: { background: colors.orange[50], @@ -43,7 +43,7 @@ export default { hover: { background: colors.orange[100], outline: colors.orange[500], - fill: colors.orange[400], + fill: colors.orange[500], text: colors.black, }, }, @@ -55,7 +55,7 @@ export default { }, warning: { background: colors.amber[50], - outline: colors.amber[400], + outline: colors.amber[300], fill: colors.amber[500], text: colors.amber[950], }, @@ -92,7 +92,7 @@ export default { active: { background: colors.sky[100], outline: colors.sky[500], - fill: colors.sky[500], + fill: colors.sky[600], text: colors.sky[950], disabled: { background: colors.sky[50], From 9549b121f23cc417acd81ca347731d2d113104ad Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 17 Jan 2024 20:51:55 +0000 Subject: [PATCH 10/12] later --- site/src/theme/light/mui.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/theme/light/mui.ts b/site/src/theme/light/mui.ts index 08ec1a02c3fe0..e508dabc3c789 100644 --- a/site/src/theme/light/mui.ts +++ b/site/src/theme/light/mui.ts @@ -9,10 +9,10 @@ const muiTheme = createTheme({ palette: { mode: "light", primary: { - main: tw.sky[500], - contrastText: tw.sky[950], - light: tw.sky[300], - dark: tw.sky[400], + main: tw.sky[600], + contrastText: tw.sky[50], + light: tw.sky[400], + dark: tw.sky[500], }, secondary: { main: tw.zinc[500], From 4a9ebb27a606462677e4b4c544817b5a0f282994 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 17 Jan 2024 21:44:50 +0000 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=A7=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/components/Dialogs/Dialog.tsx | 6 +++--- site/src/theme/experimental.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/site/src/components/Dialogs/Dialog.tsx b/site/src/components/Dialogs/Dialog.tsx index 370619e32c3dd..f526fa394d499 100644 --- a/site/src/components/Dialogs/Dialog.tsx +++ b/site/src/components/Dialogs/Dialog.tsx @@ -96,14 +96,14 @@ const styles = { }), successButton: (theme) => ({ "&.MuiButton-contained": { - backgroundColor: theme.experimental.roles.active.fill, + backgroundColor: theme.palette.success.dark, "&:not(.MuiLoadingButton-loading)": { - color: theme.experimental.roles.active.text, + color: theme.palette.primary.contrastText, }, "&:hover": { - backgroundColor: theme.experimental.roles.active.hover.fill, + backgroundColor: theme.palette.success.main, "@media (hover: none)": { backgroundColor: "transparent", 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; From 53d75bae129a43bbe43e0ae31e05506559e0278e Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 18 Jan 2024 22:04:15 +0000 Subject: [PATCH 12/12] fix new story --- .../BatchUpdateConfirmation.stories.tsx | 73 ++++++++++++++----- .../BatchUpdateConfirmation.tsx | 61 +++++++++------- site/src/testHelpers/entities.ts | 1 + 3 files changed, 88 insertions(+), 47 deletions(-) diff --git a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx index d72e67aaa54f5..b9a986150818f 100644 --- a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx +++ b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx @@ -1,34 +1,69 @@ 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, MockUser2 } from "testHelpers/entities"; -import { BatchUpdateConfirmation } from "./BatchUpdateConfirmation"; +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: [ - MockWorkspace, - { - ...MockWorkspace, - name: "Test-Workspace-2", - last_used_at: "2023-08-16T15:29:10.302441433Z", - owner_id: MockUser2.id, - owner_name: MockUser2.username, - }, - { - ...MockWorkspace, - name: "Test-Workspace-3", - last_used_at: "2023-11-16T15:29:10.302441433Z", - owner_id: MockUser2.id, - owner_name: MockUser2.username, - }, - ], + checkedWorkspaces: workspaces, }, }; diff --git a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx index 6d1cd6160d3e8..fe2b514d90556 100644 --- a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx +++ b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx @@ -5,15 +5,8 @@ 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, - useId, - useMemo, - useState, - useEffect, -} from "react"; -import { useQuery } from "react-query"; +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"; @@ -32,7 +25,7 @@ type BatchUpdateConfirmationProps = { onConfirm: () => void; }; -interface Update extends TemplateVersion { +export interface Update extends TemplateVersion { template_display_name: string; affected_workspaces: Workspace[]; } @@ -111,8 +104,8 @@ export const BatchUpdateConfirmation: FC = ({ continue; } - newVersions.set(it.template_active_version_id, { - id: it.template_active_version_id, + newVersions.set(versionId, { + id: versionId, template_display_name: it.template_display_name, affected_workspaces: [it], }); @@ -123,20 +116,23 @@ export const BatchUpdateConfirmation: FC = ({ // Not all of the information we want is included in the `Workspace` type, so we // need to query all of the versions. - const queryId = useId(); - const { data, error } = useQuery({ - queryKey: ["batchUpdate", queryId], - queryFn: () => - Promise.all( - [...newVersions.values()].map(async (version) => ({ - // ...but the query _also_ doesn't have everything we need, like the - // template display name! - ...version, - ...(await getTemplateVersion(version.id)), - })), - ), - enabled: open, + 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) { @@ -259,8 +255,17 @@ const DormantWorkspaces: FC = ({ workspaces }) => { return ( <>

      - These selected workspaces are dormant, and must be activated before they - can be updated. + {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) => ( @@ -403,7 +408,7 @@ const UsedBy: FC = ({ workspaces }) => { const workspaceNames = workspaces.map((it) => it.name); return ( -

        +

        Used by {workspaceNames.slice(0, 2).join(", ")}{" "} {workspaceNames.length > 2 && ( diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index ff84ddff76362..f759363b293b3 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1104,6 +1104,7 @@ export const MockDormantWorkspace: TypesGen.Workspace = { export const MockDormantOutdatedWorkspace: TypesGen.Workspace = { ...MockStoppedWorkspace, id: "test-dormant-outdated-workspace", + name: "Dormant-Workspace", outdated: true, dormant_at: new Date().toISOString(), };