mirror of https://github.com/coder/coder.git
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:
parent
4f08330297
commit
ef70165a8a
|
@ -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
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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 “<strong>{workspace.name}</strong>“ 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.
|
||||
<Link
|
||||
href={docs("/workspaces#workspace-resources")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more...
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from "./WorkspaceDeleteDialog";
|
|
@ -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(
|
||||
|
|
|
@ -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 ?? []}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue