Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
18 changes: 9 additions & 9 deletions site/src/components/Dialogs/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]}
>
Expand All @@ -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],
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion site/src/components/MoreMenu/MoreMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
},
};
Original file line number Diff line number Diff line change
@@ -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>
</>
}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./WorkspaceDeleteDialog";
59 changes: 58 additions & 1 deletion site/src/pages/WorkspacePage/WorkspacePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
MockTemplateVersion3,
MockUser,
MockDeploymentConfig,
MockWorkspaceBuildDelete,
} from "testHelpers/entities";
import * as api from "api/api";
import { renderWithAuth } from "testHelpers/renderHelpers";
Expand Down Expand Up @@ -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", {
Expand All @@ -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(
Expand Down
Loading