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