diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 9b62bdebba2e7..782915af565a7 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -18,6 +18,7 @@ import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage" import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage" import { UsersPage } from "./pages/UsersPage/UsersPage" import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage" +import { WorkspaceSettingsPage } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsPage" const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage")) @@ -75,14 +76,24 @@ export const AppRouter: React.FC = () => ( - - - - } - /> + + + + + } + /> + + + + } + /> + diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 460f9c447f437..b9dd2b92c978a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1,5 +1,6 @@ import axios, { AxiosRequestHeaders } from "axios" import { mutate } from "swr" +import { WorkspaceBuildTransition } from "./types" import * as TypesGen from "./typesGenerated" const CONTENT_TYPE_JSON: AxiosRequestHeaders = { @@ -132,6 +133,21 @@ export const getWorkspaceResources = async (workspaceBuildID: string): Promise + async (workspaceId: string, template_version_id?: string): Promise => { + const payload = { + transition, + template_version_id, + } + const response = await axios.post(`/api/v2/workspaces/${workspaceId}/builds`, payload) + return response.data + } + +export const startWorkspace = postWorkspaceBuild("start") +export const stopWorkspace = postWorkspaceBuild("stop") +export const deleteWorkspace = postWorkspaceBuild("delete") + export const createUser = async (user: TypesGen.CreateUserRequest): Promise => { const response = await axios.post("/api/v2/users", user) return response.data diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 637110cf37275..5ea8d8ff86285 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -10,3 +10,5 @@ export interface ReconnectingPTYRequest { readonly height?: number readonly width?: number } + +export type WorkspaceBuildTransition = "start" | "stop" | "delete" diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 816b2d633038a..f6a8ca00cd097 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -1,6 +1,7 @@ +import { action } from "@storybook/addon-actions" import { Story } from "@storybook/react" import React from "react" -import { MockOrganization, MockTemplate, MockWorkspace } from "../../testHelpers/renderHelpers" +import { MockOrganization, MockOutdatedWorkspace, MockTemplate, MockWorkspace } from "../../testHelpers/renderHelpers" import { Workspace, WorkspaceProps } from "./Workspace" export default { @@ -11,9 +12,43 @@ export default { const Template: Story = (args) => -export const Example = Template.bind({}) -Example.args = { +export const Started = Template.bind({}) +Started.args = { organization: MockOrganization, template: MockTemplate, workspace: MockWorkspace, + handleStart: action("start"), + handleStop: action("stop"), + handleRetry: action("retry"), + workspaceStatus: "started", } + +export const Starting = Template.bind({}) +Starting.args = { ...Started.args, workspaceStatus: "starting" } + +export const Stopped = Template.bind({}) +Stopped.args = { ...Started.args, workspaceStatus: "stopped" } + +export const Stopping = Template.bind({}) +Stopping.args = { ...Started.args, workspaceStatus: "stopping" } + +export const Error = Template.bind({}) +Error.args = { ...Started.args, workspaceStatus: "error" } + +export const BuildLoading = Template.bind({}) +BuildLoading.args = { ...Started.args, workspaceStatus: "loading" } + +export const Deleting = Template.bind({}) +Deleting.args = { ...Started.args, workspaceStatus: "deleting" } + +export const Deleted = Template.bind({}) +Deleted.args = { ...Started.args, workspaceStatus: "deleted" } + +export const Canceling = Template.bind({}) +Canceling.args = { ...Started.args, workspaceStatus: "canceling" } + +export const NoBreadcrumb = Template.bind({}) +NoBreadcrumb.args = { ...Started.args, template: undefined } + +export const Outdated = Template.bind({}) +Outdated.args = { ...Started.args, workspace: MockOutdatedWorkspace } diff --git a/site/src/components/Workspace/Workspace.test.tsx b/site/src/components/Workspace/Workspace.test.tsx deleted file mode 100644 index 80f247d2f061a..0000000000000 --- a/site/src/components/Workspace/Workspace.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { screen } from "@testing-library/react" -import React from "react" -import { MockOrganization, MockTemplate, MockWorkspace, render } from "../../testHelpers/renderHelpers" -import { Workspace } from "./Workspace" - -describe("Workspace", () => { - it("renders", async () => { - // When - render() - - // Then - const element = await screen.findByText(MockWorkspace.name) - expect(element).toBeDefined() - }) -}) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 3e8efdd8d271f..7e2a9f2ab0c2b 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,31 +1,51 @@ -import Box from "@material-ui/core/Box" -import Paper from "@material-ui/core/Paper" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" -import CloudCircleIcon from "@material-ui/icons/CloudCircle" import React from "react" -import { Link } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" +import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" -import * as Constants from "./constants" +import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar" export interface WorkspaceProps { - organization: TypesGen.Organization + organization?: TypesGen.Organization workspace: TypesGen.Workspace - template: TypesGen.Template + template?: TypesGen.Template + handleStart: () => void + handleStop: () => void + handleRetry: () => void + handleUpdate: () => void + workspaceStatus: WorkspaceStatus } /** * Workspace is the top-level component for viewing an individual workspace */ -export const Workspace: React.FC = ({ organization, template, workspace }) => { +export const Workspace: React.FC = ({ + organization, + template, + workspace, + handleStart, + handleStop, + handleRetry, + handleUpdate, + workspaceStatus, +}) => { const styles = useStyles() return (
- +
@@ -55,40 +75,6 @@ export const Workspace: React.FC = ({ organization, template, wo ) } -/** - * Component for the header at the top of the workspace page - */ -export const WorkspaceHeader: React.FC = ({ organization, template, workspace }) => { - const styles = useStyles() - - const templateLink = `/templates/${organization.name}/${template.name}` - - return ( - -
- -
- {workspace.name} - - {template.name} - -
-
-
- ) -} - -/** - * Component to render the 'Hero Icon' in the header of a workspace - */ -export const WorkspaceHeroIcon: React.FC = () => { - return ( - - - - ) -} - /** * Temporary placeholder component until we have the sections implemented * Can be removed once the Workspace page has all the necessary sections @@ -101,7 +87,7 @@ const Placeholder: React.FC = () => { ) } -export const useStyles = makeStyles((theme) => { +export const useStyles = makeStyles(() => { return { root: { display: "flex", @@ -115,12 +101,6 @@ export const useStyles = makeStyles((theme) => { display: "flex", flexDirection: "column", }, - section: { - border: `1px solid ${theme.palette.divider}`, - borderRadius: Constants.CardRadius, - padding: Constants.CardPadding, - margin: theme.spacing(1), - }, sidebarContainer: { display: "flex", flexDirection: "column", @@ -129,9 +109,5 @@ export const useStyles = makeStyles((theme) => { timelineContainer: { flex: 1, }, - icon: { - width: Constants.TitleIconSize, - height: Constants.TitleIconSize, - }, } }) diff --git a/site/src/components/Workspace/constants.ts b/site/src/components/Workspace/constants.ts deleted file mode 100644 index 44919f96f5399..0000000000000 --- a/site/src/components/Workspace/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const TitleIconSize = 48 -export const CardRadius = 8 -export const CardPadding = 20 diff --git a/site/src/components/WorkspaceSection/WorkspaceSection.tsx b/site/src/components/WorkspaceSection/WorkspaceSection.tsx index e5453dd259c10..bcdb90c03463c 100644 --- a/site/src/components/WorkspaceSection/WorkspaceSection.tsx +++ b/site/src/components/WorkspaceSection/WorkspaceSection.tsx @@ -2,10 +2,10 @@ import Paper from "@material-ui/core/Paper" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" import React from "react" -import { CardPadding, CardRadius } from "../Workspace/constants" +import { CardPadding, CardRadius } from "../../theme/constants" export interface WorkspaceSectionProps { - title: string + title?: string } export const WorkspaceSection: React.FC = ({ title, children }) => { @@ -13,11 +13,13 @@ export const WorkspaceSection: React.FC = ({ title, child return ( -
-
- {title} + {title && ( +
+
+ {title} +
-
+ )}
{children}
diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx new file mode 100644 index 0000000000000..0f3acc5b8093b --- /dev/null +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -0,0 +1,155 @@ +import Box from "@material-ui/core/Box" +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import React from "react" +import { Link } from "react-router-dom" +import * as TypesGen from "../../api/typesGenerated" +import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" +import { TitleIconSize } from "../../theme/constants" +import { combineClasses } from "../../util/combineClasses" +import { Stack } from "../Stack/Stack" +import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" + +export const Language = { + stop: "Stop", + start: "Start", + retry: "Retry", + update: "Update", + settings: "Settings", + started: "Running", + stopped: "Stopped", + starting: "Building", + stopping: "Stopping", + error: "Build Failed", + loading: "Loading Status", + deleting: "Deleting", + deleted: "Deleted", + // "Canceling" would be misleading because it refers to a build, not the workspace. + // So just stall. When it is canceled it will appear as the error workspaceStatus. + canceling: "Loading Status", +} + +export interface WorkspaceStatusBarProps { + organization?: TypesGen.Organization + workspace: TypesGen.Workspace + template?: TypesGen.Template + handleStart: () => void + handleStop: () => void + handleRetry: () => void + handleUpdate: () => void + workspaceStatus: WorkspaceStatus +} + +/** + * Jobs submitted while another job is in progress will be discarded, + * so check whether workspace job status has reached completion (whether successful or not). + */ +const canAcceptJobs = (workspaceStatus: WorkspaceStatus) => + ["started", "stopped", "deleted", "error"].includes(workspaceStatus) + +/** + * Component for the header at the top of the workspace page + */ +export const WorkspaceStatusBar: React.FC = ({ + organization, + template, + workspace, + handleStart, + handleStop, + handleRetry, + handleUpdate, + workspaceStatus, +}) => { + const styles = useStyles() + + const templateLink = `/templates/${organization?.name}/${template?.name}` + const settingsLink = "edit" + + return ( + + +
+
+ + {Language.settings} + +
+ + {organization && template && ( + + Back to{" "} + + {template.name} + + + )} +
+ +
+
+ {workspace.name} + + {Language[workspaceStatus]} + +
+ +
+ {workspaceStatus === "started" && ( + + )} + {workspaceStatus === "stopped" && ( + + )} + {workspaceStatus === "error" && ( + + )} + + {workspace.outdated && canAcceptJobs(workspaceStatus) && ( + + )} +
+
+
+
+ ) +} + +const useStyles = makeStyles((theme) => { + return { + link: { + textDecoration: "none", + color: theme.palette.text.primary, + }, + icon: { + width: TitleIconSize, + height: TitleIconSize, + }, + horizontal: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: theme.spacing(2), + }, + reverse: { + flexDirection: "row-reverse", + }, + statusChip: { + border: `solid 1px ${theme.palette.text.hint}`, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(1), + }, + vertical: { + display: "flex", + flexDirection: "column", + }, + } +}) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 24f67948df192..f2e02a888d429 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,8 +1,57 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ import { screen } from "@testing-library/react" +import { rest } from "msw" import React from "react" -import { MockTemplate, MockWorkspace, renderWithAuth } from "../../testHelpers/renderHelpers" +import * as api from "../../api/api" +import { Template, Workspace, WorkspaceBuild } from "../../api/typesGenerated" +import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar" +import { + MockCancelingWorkspace, + MockDeletedWorkspace, + MockDeletingWorkspace, + MockFailedWorkspace, + MockOutdatedWorkspace, + MockStartingWorkspace, + MockStoppedWorkspace, + MockStoppingWorkspace, + MockTemplate, + MockWorkspace, + MockWorkspaceBuild, + renderWithAuth, +} from "../../testHelpers/renderHelpers" +import { server } from "../../testHelpers/server" import { WorkspacePage } from "./WorkspacePage" +/** + * Requests and responses related to workspace status are unrelated, so we can't test in the usual way. + * Instead, test that button clicks produce the correct requests and that responses produce the correct UI. + * We don't need to test the UI exhaustively because Storybook does that; just enough to prove that the + * workspaceStatus was calculated correctly. + */ + +const testButton = async ( + label: string, + mock: + | jest.SpyInstance, [workspaceId: string, templateVersionId?: string | undefined]> + | jest.SpyInstance, [templateId: string]>, +) => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const button = await screen.findByText(label) + button.click() + expect(mock).toHaveBeenCalled() +} + +const testStatus = async (mock: Workspace, label: string) => { + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(mock)) + }), + ) + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const status = await screen.findByRole("status") + expect(status).toHaveTextContent(label) +} + describe("Workspace Page", () => { it("shows a workspace", async () => { renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) @@ -11,4 +60,71 @@ describe("Workspace Page", () => { expect(workspaceName).toBeDefined() expect(templateName).toBeDefined() }) + it("shows the status of the workspace", async () => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const status = await screen.findByRole("status") + expect(status).toHaveTextContent("Running") + }) + it("requests a stop job when the user presses Stop", async () => { + const stopWorkspaceMock = jest + .spyOn(api, "stopWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + testButton(Language.start, stopWorkspaceMock) + }), + it("requests a start job when the user presses Start", async () => { + const startWorkspaceMock = jest + .spyOn(api, "startWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + testButton(Language.start, startWorkspaceMock) + }), + it("requests a start job when the user presses Retry after trying to start", async () => { + const startWorkspaceMock = jest + .spyOn(api, "startWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + testButton(Language.retry, startWorkspaceMock) + }), + it("requests a stop job when the user presses Retry after trying to stop", async () => { + const stopWorkspaceMock = jest + .spyOn(api, "stopWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockStoppedWorkspace)) + }), + ) + testButton(Language.start, stopWorkspaceMock) + }), + it("requests a template when the user presses Update", async () => { + const getTemplateMock = jest.spyOn(api, "getTemplate").mockImplementation(() => Promise.resolve(MockTemplate)) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) + }), + ) + testButton(Language.update, getTemplateMock) + }), + it("shows the Stopping status when the workspace is stopping", async () => { + testStatus(MockStoppingWorkspace, Language.stopping) + }) + it("shows the Stopped status when the workspace is stopped", async () => { + testStatus(MockStoppedWorkspace, Language.stopped) + }) + it("shows the Building status when the workspace is starting", async () => { + testStatus(MockStartingWorkspace, Language.starting) + }) + it("shows the Running status when the workspace is started", async () => { + testStatus(MockWorkspace, Language.started) + }) + it("shows the Error status when the workspace is failed or canceled", async () => { + testStatus(MockFailedWorkspace, Language.error) + }) + it("shows the Loading status when the workspace is canceling", async () => { + testStatus(MockCancelingWorkspace, Language.canceling) + }) + it("shows the Deleting status when the workspace is deleting", async () => { + testStatus(MockDeletingWorkspace, Language.canceling) + }) + it("shows the Deleted status when the workspace is deleted", async () => { + testStatus(MockDeletedWorkspace, Language.canceling) + }) }) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 730953651b860..4dc6ba6ea3122 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,4 +1,4 @@ -import { useActor } from "@xstate/react" +import { useActor, useSelector } from "@xstate/react" import React, { useContext, useEffect } from "react" import { useParams } from "react-router-dom" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" @@ -8,6 +8,18 @@ import { Stack } from "../../components/Stack/Stack" import { Workspace } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" import { XServiceContext } from "../../xServices/StateContext" +import { selectWorkspaceStatus } from "../../xServices/workspace/workspaceSelectors" + +export type WorkspaceStatus = + | "started" + | "starting" + | "stopped" + | "stopping" + | "error" + | "loading" + | "deleting" + | "deleted" + | "canceling" export const WorkspacePage: React.FC = () => { const { workspace: workspaceQueryParam } = useParams() @@ -17,6 +29,7 @@ export const WorkspacePage: React.FC = () => { const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService) const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError } = workspaceState.context + const workspaceStatus = useSelector(xServices.workspaceXService, selectWorkspaceStatus) /** * Get workspace, template, and organization on mount and whenever workspaceId changes. @@ -28,13 +41,22 @@ export const WorkspacePage: React.FC = () => { if (workspaceState.matches("error")) { return - } else if (!workspace || !template || !organization) { + } else if (!workspace) { return } else { return ( - + workspaceSend("START")} + handleStop={() => workspaceSend("STOP")} + handleRetry={() => workspaceSend("RETRY")} + handleUpdate={() => workspaceSend("UPDATE")} + workspaceStatus={workspaceStatus} + /> ) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx new file mode 100644 index 0000000000000..9e50e5b828da3 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx @@ -0,0 +1,5 @@ +import React from "react" + +export const WorkspaceSettingsPage: React.FC = () => { + return
Coming soon!
+} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6d6139c9e1341..fb9c080b88af3 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -71,6 +71,13 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = { status: "succeeded", } +export const MockFailedProvisionerJob = { ...MockProvisionerJob, status: "failed" as TypesGen.ProvisionerJobStatus } +export const MockCancelingProvisionerJob = { + ...MockProvisionerJob, + status: "canceling" as TypesGen.ProvisionerJobStatus, +} +export const MockRunningProvisionerJob = { ...MockProvisionerJob, status: "running" as TypesGen.ProvisionerJobStatus } + export const MockTemplate: TypesGen.Template = { id: "test-template", created_at: "", @@ -115,6 +122,16 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { workspace_id: "test-workspace", } +export const MockWorkspaceBuildStop = { + ...MockWorkspaceBuild, + transition: "stop", +} + +export const MockWorkspaceBuildDelete = { + ...MockWorkspaceBuild, + transition: "delete", +} + export const MockWorkspace: TypesGen.Workspace = { id: "test-workspace", name: "Test-Workspace", @@ -129,6 +146,31 @@ export const MockWorkspace: TypesGen.Workspace = { latest_build: MockWorkspaceBuild, } +export const MockStoppedWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: MockWorkspaceBuildStop } +export const MockStoppingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuildStop, job: MockRunningProvisionerJob }, +} +export const MockStartingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuild, job: MockRunningProvisionerJob }, +} +export const MockCancelingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuild, job: MockCancelingProvisionerJob }, +} +export const MockFailedWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuild, job: MockFailedProvisionerJob }, +} +export const MockDeletingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuildDelete, job: MockRunningProvisionerJob }, +} +export const MockDeletedWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: MockWorkspaceBuildDelete } + +export const MockOutdatedWorkspace: TypesGen.Workspace = { ...MockWorkspace, outdated: true } + export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = { architecture: "amd64", created_at: "", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 236d372710474..b7faf6895c176 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -1,4 +1,6 @@ import { rest } from "msw" +import { WorkspaceBuildTransition } from "../api/types" +import { CreateWorkspaceBuildRequest } from "../api/typesGenerated" import { permissionsToCheck } from "../xServices/auth/authXService" import * as M from "./entities" @@ -80,6 +82,16 @@ export const handlers = [ rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => { return res(ctx.status(200)) }), + rest.post("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => { + const { transition } = req.body as CreateWorkspaceBuildRequest + const transitionToBuild = { + start: M.MockWorkspaceBuild, + stop: M.MockWorkspaceBuildStop, + delete: M.MockWorkspaceBuildDelete, + } + const result = transitionToBuild[transition as WorkspaceBuildTransition] + return res(ctx.status(200), ctx.json(result)) + }), // workspace builds rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => { diff --git a/site/src/theme/constants.ts b/site/src/theme/constants.ts index fd07a3abcb26f..79bc3444d5edf 100644 --- a/site/src/theme/constants.ts +++ b/site/src/theme/constants.ts @@ -9,3 +9,6 @@ export const emptyBoxShadow = "none" export const navHeight = 56 export const maxWidth = 1380 export const sidePadding = "50px" +export const TitleIconSize = 48 +export const CardRadius = 8 +export const CardPadding = 20 diff --git a/site/src/xServices/workspace/workspaceSelectors.ts b/site/src/xServices/workspace/workspaceSelectors.ts new file mode 100644 index 0000000000000..b1dffa9d1cc62 --- /dev/null +++ b/site/src/xServices/workspace/workspaceSelectors.ts @@ -0,0 +1,37 @@ +import { State } from "xstate" +import { WorkspaceBuildTransition } from "../../api/types" +import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" +import { WorkspaceContext, WorkspaceEvent } from "./workspaceXService" + +const inProgressToStatus: Record = { + start: "starting", + stop: "stopping", + delete: "deleting", +} + +const succeededToStatus: Record = { + start: "started", + stop: "stopped", + delete: "deleted", +} + +export const selectWorkspaceStatus = (state: State): WorkspaceStatus => { + const transition = state.context.workspace?.latest_build.transition as WorkspaceBuildTransition + const jobStatus = state.context.workspace?.latest_build.job.status + switch (jobStatus) { + case undefined: + return "loading" + case "succeeded": + return succeededToStatus[transition] + case "pending": + return inProgressToStatus[transition] + case "running": + return inProgressToStatus[transition] + case "canceling": + return "canceling" + case "canceled": + return "error" + case "failed": + return "error" + } +} diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 0b01e503ec50d..a64633595466c 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -1,17 +1,34 @@ import { assign, createMachine } from "xstate" import * as API from "../../api/api" import * as TypesGen from "../../api/typesGenerated" +import { displayError } from "../../components/GlobalSnackbar/utils" -interface WorkspaceContext { +const Language = { + refreshTemplateError: "Error updating workspace: latest template could not be fetched.", + buildError: "Workspace action failed.", +} + +export interface WorkspaceContext { workspace?: TypesGen.Workspace template?: TypesGen.Template organization?: TypesGen.Organization + build?: TypesGen.WorkspaceBuild getWorkspaceError?: Error | unknown getTemplateError?: Error | unknown getOrganizationError?: Error | unknown + // error creating a new WorkspaceBuild + buildError?: Error | unknown + // these are separate from getX errors because they don't make the page unusable + refreshWorkspaceError: Error | unknown + refreshTemplateError: Error | unknown } -type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string } +export type WorkspaceEvent = + | { type: "GET_WORKSPACE"; workspaceId: string } + | { type: "START" } + | { type: "STOP" } + | { type: "RETRY" } + | { type: "UPDATE" } export const workspaceMachine = createMachine( { @@ -29,23 +46,34 @@ export const workspaceMachine = createMachine( getOrganization: { data: TypesGen.Organization } + startWorkspace: { + data: TypesGen.WorkspaceBuild + } + stopWorkspace: { + data: TypesGen.WorkspaceBuild + } + refreshWorkspace: { + data: TypesGen.Workspace | undefined + } }, }, id: "workspaceState", initial: "idle", + on: { + GET_WORKSPACE: "gettingWorkspace", + }, states: { idle: { - on: { - GET_WORKSPACE: "gettingWorkspace", - }, + tags: "loading", }, gettingWorkspace: { + entry: ["clearGetWorkspaceError", "clearContext"], invoke: { src: "getWorkspace", id: "getWorkspace", onDone: { - target: "gettingTemplate", - actions: ["assignWorkspace", "clearGetWorkspaceError"], + target: "ready", + actions: ["assignWorkspace"], }, onError: { target: "error", @@ -54,35 +82,125 @@ export const workspaceMachine = createMachine( }, tags: "loading", }, - gettingTemplate: { - invoke: { - src: "getTemplate", - id: "getTemplate", - onDone: { - target: "gettingOrganization", - actions: ["assignTemplate", "clearGetTemplateError"], - }, - onError: { - target: "error", - actions: "assignGetTemplateError", + ready: { + type: "parallel", + states: { + // We poll the workspace consistently to know if it becomes outdated and to update build status + pollingWorkspace: { + initial: "refreshingWorkspace", + states: { + refreshingWorkspace: { + entry: "clearRefreshWorkspaceError", + invoke: { + id: "refreshWorkspace", + src: "refreshWorkspace", + onDone: { target: "waiting", actions: "assignWorkspace" }, + onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, + }, + }, + waiting: { + after: { + 1000: "refreshingWorkspace", + }, + }, + }, }, - }, - tags: "loading", - }, - gettingOrganization: { - invoke: { - src: "getOrganization", - id: "getOrganization", - onDone: { - target: "idle", - actions: ["assignOrganization", "clearGetOrganizationError"], + breadcrumb: { + initial: "gettingTemplate", + states: { + gettingTemplate: { + invoke: { + src: "getTemplate", + id: "getTemplate", + onDone: { + target: "gettingOrganization", + actions: ["assignTemplate", "clearGetTemplateError"], + }, + onError: { + target: "error", + actions: "assignGetTemplateError", + }, + }, + tags: "loading", + }, + gettingOrganization: { + invoke: { + src: "getOrganization", + id: "getOrganization", + onDone: { + target: "ready", + actions: ["assignOrganization", "clearGetOrganizationError"], + }, + onError: { + target: "error", + actions: "assignGetOrganizationError", + }, + }, + tags: "loading", + }, + error: {}, + ready: {}, + }, }, - onError: { - target: "error", - actions: "assignGetOrganizationError", + build: { + initial: "idle", + states: { + idle: { + on: { + START: "requestingStart", + STOP: "requestingStop", + RETRY: [{ cond: "triedToStart", target: "requestingStart" }, { target: "requestingStop" }], + UPDATE: "refreshingTemplate", + }, + }, + requestingStart: { + entry: "clearBuildError", + invoke: { + id: "startWorkspace", + src: "startWorkspace", + onDone: { + target: "idle", + actions: "assignBuild", + }, + onError: { + target: "idle", + actions: ["assignBuildError", "displayBuildError"], + }, + }, + }, + requestingStop: { + entry: "clearBuildError", + invoke: { + id: "stopWorkspace", + src: "stopWorkspace", + onDone: { + target: "idle", + actions: "assignBuild", + }, + onError: { + target: "idle", + actions: ["assignBuildError", "displayBuildError"], + }, + }, + }, + refreshingTemplate: { + entry: "clearRefreshTemplateError", + invoke: { + id: "refreshTemplate", + src: "getTemplate", + onDone: { + target: "requestingStart", + actions: "assignTemplate", + }, + onError: { + target: "idle", + actions: ["assignRefreshTemplateError", "displayRefreshTemplateError"], + }, + }, + }, + }, }, }, - tags: "loading", }, error: { on: { @@ -93,6 +211,14 @@ export const workspaceMachine = createMachine( }, { actions: { + // Clear data about an old workspace when looking at a new one + clearContext: () => + assign({ + workspace: undefined, + template: undefined, + organization: undefined, + build: undefined, + }), assignWorkspace: assign({ workspace: (_, event) => event.data, }), @@ -114,6 +240,43 @@ export const workspaceMachine = createMachine( getOrganizationError: (_, event) => event.data, }), clearGetOrganizationError: (context) => assign({ ...context, getOrganizationError: undefined }), + assignBuild: (_, event) => + assign({ + build: event.data, + }), + assignBuildError: (_, event) => + assign({ + buildError: event.data, + }), + displayBuildError: () => { + displayError(Language.buildError) + }, + clearBuildError: (_) => + assign({ + buildError: undefined, + }), + assignRefreshWorkspaceError: (_, event) => + assign({ + refreshWorkspaceError: event.data, + }), + clearRefreshWorkspaceError: (_) => + assign({ + refreshWorkspaceError: undefined, + }), + assignRefreshTemplateError: (_, event) => + assign({ + refreshTemplateError: event.data, + }), + displayRefreshTemplateError: () => { + displayError(Language.refreshTemplateError) + }, + clearRefreshTemplateError: (_) => + assign({ + refreshTemplateError: undefined, + }), + }, + guards: { + triedToStart: (context) => context.workspace?.latest_build.transition === "start", }, services: { getWorkspace: async (_, event) => { @@ -133,6 +296,27 @@ export const workspaceMachine = createMachine( throw Error("Cannot get organization without template") } }, + startWorkspace: async (context) => { + if (context.workspace) { + return await API.startWorkspace(context.workspace.id, context.template?.active_version_id) + } else { + throw Error("Cannot start workspace without workspace id") + } + }, + stopWorkspace: async (context) => { + if (context.workspace) { + return await API.stopWorkspace(context.workspace.id) + } else { + throw Error("Cannot stop workspace without workspace id") + } + }, + refreshWorkspace: async (context) => { + if (context.workspace) { + return await API.getWorkspace(context.workspace.id) + } else { + throw Error("Cannot refresh workspace without id") + } + }, }, }, )