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(), }; 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