coder/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx

498 lines
14 KiB
TypeScript

import type { Interpolation, Theme } from "@emotion/react";
import InstallDesktopIcon from "@mui/icons-material/InstallDesktop";
import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined";
import ScheduleIcon from "@mui/icons-material/Schedule";
import SettingsSuggestIcon from "@mui/icons-material/SettingsSuggest";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
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 { ErrorAlert } from "components/Alert/ErrorAlert";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Loader } from "components/Loader/Loader";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
import { Stack } from "components/Stack/Stack";
dayjs.extend(relativeTime);
type BatchUpdateConfirmationProps = {
checkedWorkspaces: readonly Workspace[];
open: boolean;
isLoading: boolean;
onClose: () => void;
onConfirm: () => void;
};
export interface Update extends TemplateVersion {
template_display_name: string;
affected_workspaces: readonly 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(() => {
type MutableUpdateInfo = {
id: string;
template_display_name: string;
affected_workspaces: Workspace[];
};
const newVersions = new Map<string, MutableUpdateInfo>();
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],
});
}
type ReadonlyUpdateInfo = Readonly<MutableUpdateInfo> & {
affected_workspaces: readonly Workspace[];
};
return newVersions as Map<string, ReadonlyUpdateInfo>;
}, [workspacesToUpdate]);
// Not all of the information we want is included in the `Workspace` type, so we
// need to query all of the versions.
const results = useQueries({
queries: [...newVersions.values()].map((version) => ({
queryKey: ["batchUpdate", version.id],
queryFn: async () => ({
// ...but the query _also_ doesn't have everything we need, like the
// template display name!
...version,
...(await getTemplateVersion(version.id)),
}),
})),
});
const { data, error } = {
data: results.every((result) => result.isSuccess && result.data)
? results.map((result) => result.data!)
: undefined,
error: results.some((result) => result.error),
};
const onProceed = () => {
switch (stage) {
case "updates":
onConfirm();
break;
case "dormantWorkspaces":
setStage("updates");
break;
case "consequences":
setStage(
dormantWorkspaces.length > 0 ? "dormantWorkspaces" : "updates",
);
break;
}
};
const workspaceCount = `${workspacesToUpdate.length} ${
workspacesToUpdate.length === 1 ? "workspace" : "workspaces"
}`;
let confirmText: ReactNode = <>Review updates&hellip;</>;
if (stage === "updates") {
confirmText = <>Update {workspaceCount}</>;
}
return (
<ConfirmDialog
open={open}
onClose={onClose}
title={`Update ${workspaceCount}`}
hideCancel
confirmLoading={isLoading}
confirmText={confirmText}
onConfirm={onProceed}
description={
<>
{stage === "consequences" && (
<Consequences runningWorkspaces={runningWorkspacesToUpdate} />
)}
{stage === "dormantWorkspaces" && (
<DormantWorkspaces workspaces={dormantWorkspaces} />
)}
{stage === "updates" && (
<Updates
workspaces={workspacesToUpdate}
updates={data}
error={error}
/>
)}
</>
}
/>
);
};
interface ConsequencesProps {
runningWorkspaces: Workspace[];
}
const Consequences: FC<ConsequencesProps> = ({ runningWorkspaces }) => {
const workspaceCount = `${runningWorkspaces.length} ${
runningWorkspaces.length === 1 ? "running workspace" : "running workspaces"
}`;
const owners = new Set(runningWorkspaces.map((it) => it.owner_id)).size;
const ownerCount = `${owners} ${owners === 1 ? "owner" : "owners"}`;
return (
<>
<p>You are about to update {workspaceCount}.</p>
<ul css={styles.consequences}>
<li>
Updating will stop all running processes and delete non-persistent
data.
</li>
<li>
Anyone connected to a running workspace will be disconnected until the
update is complete.
</li>
<li>Any unsaved data will be lost.</li>
</ul>
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={styles.summary}
>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span>{ownerCount}</span>
</Stack>
</Stack>
</>
);
};
interface DormantWorkspacesProps {
workspaces: Workspace[];
}
const DormantWorkspaces: FC<DormantWorkspacesProps> = ({ workspaces }) => {
const mostRecent = workspaces.reduce(
(latestSoFar, against) => {
if (!latestSoFar) {
return against;
}
return new Date(against.last_used_at).getTime() >
new Date(latestSoFar.last_used_at).getTime()
? against
: latestSoFar;
},
undefined as Workspace | undefined,
);
const owners = new Set(workspaces.map((it) => it.owner_id)).size;
const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`;
return (
<>
<p>
{workspaces.length === 1 ? (
<>
This selected workspace is dormant, and must be activated before it
can be updated.
</>
) : (
<>
These selected workspaces are dormant, and must be activated before
they can be updated.
</>
)}
</p>
<ul css={styles.workspacesList}>
{workspaces.map((workspace) => (
<li key={workspace.id} css={styles.workspace}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<span css={styles.name}>{workspace.name}</span>
<Stack css={{ gap: 0, fontSize: 14, width: 128 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{workspace.owner_name}
</span>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<ScheduleIcon css={styles.summaryIcon} />
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{lastUsed(workspace.last_used_at)}
</span>
</Stack>
</Stack>
</Stack>
</li>
))}
</ul>
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={styles.summary}
>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span>{ownersCount}</span>
</Stack>
{mostRecent && (
<Stack direction="row" alignItems="center" spacing={1}>
<ScheduleIcon css={styles.summaryIcon} />
<span>Last used {lastUsed(mostRecent.last_used_at)}</span>
</Stack>
)}
</Stack>
</>
);
};
interface UpdatesProps {
workspaces: Workspace[];
updates?: Update[];
error?: unknown;
}
const Updates: FC<UpdatesProps> = ({ workspaces, updates, error }) => {
const workspaceCount = `${workspaces.length} ${
workspaces.length === 1 ? "outdated workspace" : "outdated workspaces"
}`;
const updateCount =
updates &&
`${updates.length} ${
updates.length === 1 ? "new version" : "new versions"
}`;
return (
<>
<TemplateVersionMessages updates={updates} error={error} />
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={styles.summary}
>
<Stack direction="row" alignItems="center" spacing={1}>
<InstallDesktopIcon css={styles.summaryIcon} />
<span>{workspaceCount}</span>
</Stack>
{updateCount && (
<Stack direction="row" alignItems="center" spacing={1}>
<SettingsSuggestIcon css={styles.summaryIcon} />
<span>{updateCount}</span>
</Stack>
)}
</Stack>
</>
);
};
interface TemplateVersionMessagesProps {
error?: unknown;
updates?: Update[];
}
const TemplateVersionMessages: FC<TemplateVersionMessagesProps> = ({
error,
updates,
}) => {
if (error) {
return <ErrorAlert error={error} />;
}
if (!updates) {
return <Loader />;
}
return (
<ul css={styles.updatesList}>
{updates.map((update) => (
<li key={update.id} css={styles.workspace}>
<Stack spacing={0}>
<Stack spacing={0.5} direction="row" alignItems="center">
<span css={styles.name}>{update.template_display_name}</span>
<span css={styles.newVersion}>&rarr; {update.name}</span>
</Stack>
<MemoizedInlineMarkdown
allowedElements={["ol", "ul", "li"]}
css={styles.message}
>
{update.message ?? "No message"}
</MemoizedInlineMarkdown>
<UsedBy workspaces={update.affected_workspaces} />
</Stack>
</li>
))}
</ul>
);
};
interface UsedByProps {
workspaces: readonly 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.roles.active.fill.solid,
}),
message: {
fontSize: 14,
},
summary: {
gap: "6px 20px",
fontSize: 14,
},
} satisfies Record<string, Interpolation<Theme>>;