feat: add `orphan` option to workspace delete in UI (#10654)

* added workspace delete dialog

* added stories and tests

* PR review

* fix flake

* fixed stories
This commit is contained in:
Kira Pilot 2023-11-14 11:32:05 -05:00 committed by GitHub
parent 4f08330297
commit ef70165a8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 319 additions and 28 deletions

View File

@ -115,7 +115,12 @@ though the exact behavior depends on the template. For more information, see
> You can use `coder show <workspace-name>` to see which resources are
> persistent and which are ephemeral.
When a workspace is deleted, all of the workspace's resources are deleted.
Typically, when a workspace is deleted, all of the workspace's resources are
deleted along with it. Rarely, one may wish to delete a workspace without
deleting its resources, e.g. a workspace in a broken state. Users with the
Template Admin role have the option to do so both in the UI, and also in the CLI
by running the `delete` command with the `--orphan` flag. This option should be
considered cautiously as orphaning may lead to unaccounted cloud resources.
## Repairing workspaces

View File

@ -545,11 +545,11 @@ export const stopWorkspace = (
export const deleteWorkspace = (
workspaceId: string,
logLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"],
options?: Pick<TypesGen.CreateWorkspaceBuildRequest, "log_level" & "orphan">,
) =>
postWorkspaceBuild(workspaceId, {
transition: "delete",
log_level: logLevel,
...options,
});
export const cancelWorkspaceBuild = async (

View File

@ -59,7 +59,7 @@ export const DialogActionButtons: React.FC<DialogActionButtonsProps> = ({
disabled={disabled}
type="submit"
css={[
type === "delete" && styles.errorButton,
type === "delete" && styles.warningButton,
type === "success" && styles.successButton,
]}
>
@ -71,26 +71,26 @@ export const DialogActionButtons: React.FC<DialogActionButtonsProps> = ({
};
const styles = {
errorButton: (theme) => ({
warningButton: (theme) => ({
"&.MuiButton-contained": {
backgroundColor: colors.red[10],
borderColor: colors.red[9],
backgroundColor: colors.orange[12],
borderColor: colors.orange[9],
"&:not(.MuiLoadingButton-loading)": {
color: theme.palette.text.primary,
},
"&:hover:not(:disabled)": {
backgroundColor: colors.red[9],
borderColor: colors.red[9],
backgroundColor: colors.orange[9],
borderColor: colors.orange[9],
},
"&.Mui-disabled": {
backgroundColor: colors.red[15],
borderColor: colors.red[15],
backgroundColor: colors.orange[14],
borderColor: colors.orange[15],
"&:not(.MuiLoadingButton-loading)": {
color: colors.red[9],
color: colors.orange[12],
},
},
},

View File

@ -113,7 +113,7 @@ export const MoreMenuItem = (
{...menuItemProps}
css={(theme) => ({
fontSize: 14,
color: danger ? theme.palette.error.light : undefined,
color: danger ? theme.palette.warning.light : undefined,
"& .MuiSvgIcon-root": {
width: 16,
height: 16,

View File

@ -0,0 +1,32 @@
import { type ComponentProps } from "react";
import { Meta, StoryObj } from "@storybook/react";
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
import { MockWorkspace } from "testHelpers/entities";
const meta: Meta<typeof WorkspaceDeleteDialog> = {
title: "pages/WorkspacePage/WorkspaceDeleteDialog",
component: WorkspaceDeleteDialog,
};
export default meta;
type Story = StoryObj<typeof WorkspaceDeleteDialog>;
const args: ComponentProps<typeof WorkspaceDeleteDialog> = {
workspace: MockWorkspace,
canUpdateTemplate: false,
isOpen: true,
onCancel: () => {},
onConfirm: () => {},
workspaceBuildDateStr: "2 days ago",
};
export const NotTemplateAdmin: Story = {
args,
};
export const TemplateAdmin: Story = {
args: {
...args,
canUpdateTemplate: true,
},
};

View File

@ -0,0 +1,189 @@
import { Workspace, CreateWorkspaceBuildRequest } from "api/typesGenerated";
import { useId, useState, FormEvent } from "react";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { type Interpolation, type Theme } from "@emotion/react";
import { colors } from "theme/colors";
import TextField from "@mui/material/TextField";
import { docs } from "utils/docs";
import Link from "@mui/material/Link";
import Checkbox from "@mui/material/Checkbox";
const styles = {
workspaceInfo: (theme) => ({
display: "flex",
justifyContent: "space-between",
backgroundColor: colors.gray[14],
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
padding: 12,
marginBottom: 20,
lineHeight: "1.3em",
"& .name": {
fontSize: 18,
fontWeight: 800,
color: theme.palette.text.primary,
},
"& .label": {
fontSize: 11,
color: theme.palette.text.secondary,
},
"& .info": {
fontSize: 14,
fontWeight: 500,
color: theme.palette.text.primary,
},
}),
orphanContainer: () => ({
marginTop: 24,
display: "flex",
backgroundColor: colors.orange[15],
justifyContent: "space-between",
border: `1px solid ${colors.orange[11]}`,
borderRadius: 8,
padding: 12,
lineHeight: "18px",
"& .option": {
color: colors.orange[11],
"&.Mui-checked": {
color: colors.orange[11],
},
},
"& .info": {
fontSize: "14px",
color: colors.orange[10],
fontWeight: 500,
},
}),
} satisfies Record<string, Interpolation<Theme>>;
interface WorkspaceDeleteDialogProps {
workspace: Workspace;
canUpdateTemplate: boolean;
isOpen: boolean;
onCancel: () => void;
onConfirm: (arg: CreateWorkspaceBuildRequest["orphan"]) => void;
workspaceBuildDateStr: string;
}
export const WorkspaceDeleteDialog = (props: WorkspaceDeleteDialogProps) => {
const {
workspace,
canUpdateTemplate,
isOpen,
onCancel,
onConfirm,
workspaceBuildDateStr,
} = props;
const hookId = useId();
const [userConfirmationText, setUserConfirmationText] = useState("");
const [orphanWorkspace, setOrphanWorkspace] =
useState<CreateWorkspaceBuildRequest["orphan"]>(false);
const [isFocused, setIsFocused] = useState(false);
const deletionConfirmed = workspace.name === userConfirmationText;
const onSubmit = (event: FormEvent) => {
event.preventDefault();
if (deletionConfirmed) {
onConfirm(orphanWorkspace);
}
};
const hasError = !deletionConfirmed && userConfirmationText.length > 0;
const displayErrorMessage = hasError && !isFocused;
const inputColor = hasError ? "error" : "primary";
return (
<ConfirmDialog
type="delete"
hideCancel={false}
open={isOpen}
title="Delete Workspace"
onConfirm={() => onConfirm(orphanWorkspace)}
onClose={onCancel}
disabled={!deletionConfirmed}
description={
<>
<div css={styles.workspaceInfo}>
<div>
<p className="name">{workspace.name}</p>
<p className="label">workspace</p>
</div>
<div>
<p className="info">{workspaceBuildDateStr}</p>
<p className="label">created</p>
</div>
</div>
<p>Deleting this workspace is irreversible!</p>
<p>
Type &ldquo;<strong>{workspace.name}</strong>&ldquo; below to
confirm:
</p>
<form onSubmit={onSubmit}>
<TextField
fullWidth
autoFocus
css={{ marginTop: 32 }}
name="confirmation"
autoComplete="off"
id={`${hookId}-confirm`}
placeholder={workspace.name}
value={userConfirmationText}
onChange={(event) => setUserConfirmationText(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
label="Workspace name"
color={inputColor}
error={displayErrorMessage}
helperText={
displayErrorMessage &&
`${userConfirmationText} does not match the name of this workspace`
}
InputProps={{ color: inputColor }}
inputProps={{
"data-testid": "delete-dialog-name-confirmation",
}}
/>
{canUpdateTemplate && (
<div css={styles.orphanContainer}>
<div css={{ flexDirection: "column" }}>
<Checkbox
id="orphan_resources"
size="small"
color="warning"
onChange={() => {
setOrphanWorkspace(!orphanWorkspace);
}}
className="option"
name="orphan_resources"
checked={orphanWorkspace}
data-testid="orphan-checkbox"
/>
</div>
<div css={{ flexDirection: "column" }}>
<p className="info">Orphan resources</p>
<span css={{ fontSize: "11px" }}>
Skip resource cleanup. Resources such as volumes and virtual
machines will not be destroyed.&nbsp;
<Link
href={docs("/workspaces#workspace-resources")}
target="_blank"
rel="noreferrer"
>
Learn more...
</Link>
</span>
</div>
</div>
)}
</form>
</>
}
/>
);
};

View File

@ -0,0 +1 @@
export * from "./WorkspaceDeleteDialog";

View File

@ -15,6 +15,7 @@ import {
MockTemplateVersion3,
MockUser,
MockDeploymentConfig,
MockWorkspaceBuildDelete,
} from "testHelpers/entities";
import * as api from "api/api";
import { renderWithAuth } from "testHelpers/renderHelpers";
@ -90,7 +91,7 @@ describe("WorkspacePage", () => {
// Get dialog and confirm
const dialog = await screen.findByTestId("dialog");
const labelText = "Name of the workspace to delete";
const labelText = "Workspace name";
const textField = within(dialog).getByLabelText(labelText);
await user.type(textField, MockWorkspace.name);
const confirmButton = within(dialog).getByRole("button", {
@ -101,6 +102,62 @@ describe("WorkspacePage", () => {
expect(deleteWorkspaceMock).toBeCalled();
});
it("orphans the workspace on delete if option is selected", async () => {
const user = userEvent.setup({ delay: 0 });
// set permissions
server.use(
rest.post("/api/v2/authcheck", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
updateTemplates: true,
updateWorkspace: true,
updateTemplate: true,
}),
);
}),
);
const deleteWorkspaceMock = jest
.spyOn(api, "deleteWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuildDelete);
await renderWorkspacePage();
// open the workspace action popover so we have access to all available ctas
const trigger = screen.getByTestId("workspace-options-button");
await user.click(trigger);
// Click on delete
const button = await screen.findByTestId("delete-button");
await user.click(button);
// Get dialog and enter confirmation text
const dialog = await screen.findByTestId("dialog");
const labelText = "Workspace name";
const textField = within(dialog).getByLabelText(labelText);
await user.type(textField, MockWorkspace.name);
// check orphan option
const orphanCheckbox = within(
screen.getByTestId("orphan-checkbox"),
).getByRole("checkbox");
await user.click(orphanCheckbox);
// confirm
const confirmButton = within(dialog).getByRole("button", {
name: "Delete",
hidden: false,
});
await user.click(confirmButton);
// arguments are workspace.name, log level (undefined), and orphan
expect(deleteWorkspaceMock).toBeCalledWith(MockWorkspace.id, {
log_level: undefined,
orphan: true,
});
});
it("requests a start job when the user presses Start", async () => {
server.use(
rest.get(

View File

@ -1,5 +1,4 @@
import { useDashboard } from "components/Dashboard/DashboardProvider";
import dayjs from "dayjs";
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
import { FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
@ -11,7 +10,6 @@ import {
getMinDeadline,
} from "utils/schedule";
import { StateFrom } from "xstate";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { Workspace, WorkspaceErrors } from "./Workspace";
import { pageTitle } from "utils/page";
import { getFaviconByStatus, hasJobError } from "utils/workspace";
@ -36,6 +34,8 @@ import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import { decreaseDeadline, increaseDeadline } from "api/queries/workspaces";
import { getErrorMessage } from "api/errors";
import { displaySuccess, displayError } from "components/GlobalSnackbar/utils";
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
import dayjs from "dayjs";
interface WorkspaceReadyPageProps {
workspaceState: StateFrom<typeof workspaceMachine>;
@ -217,17 +217,15 @@ export const WorkspaceReadyPage = ({
}
canAutostart={canAutostart}
/>
<DeleteDialog
entity="workspace"
name={workspace.name}
info={`This workspace was created ${dayjs(
workspace.created_at,
).fromNow()}.`}
<WorkspaceDeleteDialog
workspace={workspace}
canUpdateTemplate={canUpdateTemplate}
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
onCancel={() => workspaceSend({ type: "CANCEL_DELETE" })}
onConfirm={() => {
workspaceSend({ type: "DELETE" });
onConfirm={(orphan) => {
workspaceSend({ type: "DELETE", orphan });
}}
workspaceBuildDateStr={dayjs(workspace.created_at).fromNow()}
/>
<UpdateBuildParametersDialog
missedParameters={missedParameters ?? []}

View File

@ -42,7 +42,10 @@ export type WorkspaceEvent =
| { type: "START"; buildParameters?: TypesGen.WorkspaceBuildParameter[] }
| { type: "STOP" }
| { type: "ASK_DELETE" }
| { type: "DELETE" }
| {
type: "DELETE";
orphan: TypesGen.CreateWorkspaceBuildRequest["orphan"];
}
| { type: "CANCEL_DELETE" }
| { type: "UPDATE"; buildParameters?: TypesGen.WorkspaceBuildParameter[] }
| {
@ -57,7 +60,10 @@ export type WorkspaceEvent =
| { type: "EVENT_SOURCE_ERROR"; error: unknown }
| { type: "INCREASE_DEADLINE"; hours: number }
| { type: "DECREASE_DEADLINE"; hours: number }
| { type: "RETRY_BUILD" }
| {
type: "RETRY_BUILD";
orphan?: TypesGen.CreateWorkspaceBuildRequest["orphan"];
}
| { type: "ACTIVATE" };
export const checks = {
@ -589,11 +595,14 @@ export const workspaceMachine = createMachine(
throw Error("Cannot stop workspace without workspace id");
}
},
deleteWorkspace: (context) => async (send) => {
deleteWorkspace: (context, data) => async (send) => {
if (context.workspace) {
const deleteWorkspacePromise = await API.deleteWorkspace(
context.workspace.id,
context.createBuildLogLevel,
{
log_level: context.createBuildLogLevel,
orphan: data.orphan,
},
);
send({ type: "REFRESH_TIMELINE" });
return deleteWorkspacePromise;