diff --git a/docs/workspaces.md b/docs/workspaces.md index ad5001ee82cfe..499a989a11d6e 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -115,7 +115,12 @@ though the exact behavior depends on the template. For more information, see > You can use `coder show ` 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 diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0f1d75b0bb9b9..2379d00cf114d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -545,11 +545,11 @@ export const stopWorkspace = ( export const deleteWorkspace = ( workspaceId: string, - logLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"], + options?: Pick, ) => postWorkspaceBuild(workspaceId, { transition: "delete", - log_level: logLevel, + ...options, }); export const cancelWorkspaceBuild = async ( diff --git a/site/src/components/Dialogs/Dialog.tsx b/site/src/components/Dialogs/Dialog.tsx index 535f151a83dc1..c64a48c454ae1 100644 --- a/site/src/components/Dialogs/Dialog.tsx +++ b/site/src/components/Dialogs/Dialog.tsx @@ -59,7 +59,7 @@ export const DialogActionButtons: React.FC = ({ 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 = ({ }; 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], }, }, }, diff --git a/site/src/components/MoreMenu/MoreMenu.tsx b/site/src/components/MoreMenu/MoreMenu.tsx index 1d7cd6d1d17b6..f1379e2bf3204 100644 --- a/site/src/components/MoreMenu/MoreMenu.tsx +++ b/site/src/components/MoreMenu/MoreMenu.tsx @@ -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, diff --git a/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.stories.tsx new file mode 100644 index 0000000000000..3e6c4a555ef2e --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.stories.tsx @@ -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 = { + title: "pages/WorkspacePage/WorkspaceDeleteDialog", + component: WorkspaceDeleteDialog, +}; + +export default meta; +type Story = StoryObj; + +const args: ComponentProps = { + 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, + }, +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.tsx b/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.tsx new file mode 100644 index 0000000000000..d2e6369b78e4e --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.tsx @@ -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>; + +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(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 ( + onConfirm(orphanWorkspace)} + onClose={onCancel} + disabled={!deletionConfirmed} + description={ + <> +
+
+

{workspace.name}

+

workspace

+
+
+

{workspaceBuildDateStr}

+

created

+
+
+ +

Deleting this workspace is irreversible!

+

+ Type “{workspace.name}“ below to + confirm: +

+ +
+ 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 && ( +
+
+ { + setOrphanWorkspace(!orphanWorkspace); + }} + className="option" + name="orphan_resources" + checked={orphanWorkspace} + data-testid="orphan-checkbox" + /> +
+
+

Orphan resources

+ + Skip resource cleanup. Resources such as volumes and virtual + machines will not be destroyed.  + + Learn more... + + +
+
+ )} + + + } + /> + ); +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/index.ts b/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/index.ts new file mode 100644 index 0000000000000..cc0251b4a2ce0 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/index.ts @@ -0,0 +1 @@ +export * from "./WorkspaceDeleteDialog"; diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 8a79bccea4513..fa9096b088f66 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -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( diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 766d7a356e5f3..11185f5a9012f 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -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; @@ -217,17 +217,15 @@ export const WorkspaceReadyPage = ({ } canAutostart={canAutostart} /> - workspaceSend({ type: "CANCEL_DELETE" })} - onConfirm={() => { - workspaceSend({ type: "DELETE" }); + onConfirm={(orphan) => { + workspaceSend({ type: "DELETE", orphan }); }} + workspaceBuildDateStr={dayjs(workspace.created_at).fromNow()} /> 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;