feat: Workspace StatusBar (#1362)

* Move component and prep

* Make WorkspaceSection more reusable

* Lay out elements

* Layout tweaks

* Add outdated to Workspace type

* Fill out status bar component

* Format

* Add transition to types

* Add api handlers for build toggle

* Format

* Parallelize machine

* Lay out basics of build submachine

* Pipe start and stop events through - needs status

* Attempt at a machine

It's so big, but collapsing start and stop made it hard to distinguish retry from toggle

* Update mock

* Render status and buttons

* Fix type error on template page

* Move Settings

* Format

* Keep refreshed workspace

* Make it switch workspaces

* Lint

* Fix relative api path

* Test

* Fix polling

* Add loading workspace state

* Format

* Add stub settings page

* Format

* Lint

* Get rid of let

* Add update

* Make start use version id

Important for update

* Fix imports

* Add polling for outdated

* Rely on context instead of finite state for status

* Handle canceling

* Fix tests

* Format

* Display errors so users know when button presses didn't work

* Fix api typo, remove logging

* Lint

* Simplify type

Co-authored-by: G r e y <grey@coder.com>

* Add type, extract helper

Co-authored-by: G r e y <grey@coder.com>
This commit is contained in:
Presley Pizzo
2022-05-16 12:34:22 -04:00
committed by GitHub
parent e990a9ac28
commit b06ef0ae6e
17 changed files with 726 additions and 126 deletions

View File

@ -18,6 +18,7 @@ import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage" import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
import { UsersPage } from "./pages/UsersPage/UsersPage" import { UsersPage } from "./pages/UsersPage/UsersPage"
import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage" import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage"
import { WorkspaceSettingsPage } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"
const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage")) const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage"))
@ -75,14 +76,24 @@ export const AppRouter: React.FC = () => (
</Route> </Route>
<Route path="workspaces"> <Route path="workspaces">
<Route <Route path=":workspace">
path=":workspace" <Route
element={ index
<AuthAndFrame> element={
<WorkspacePage /> <AuthAndFrame>
</AuthAndFrame> <WorkspacePage />
} </AuthAndFrame>
/> }
/>
<Route
path="edit"
element={
<AuthAndFrame>
<WorkspaceSettingsPage />
</AuthAndFrame>
}
/>
</Route>
</Route> </Route>
<Route path="users"> <Route path="users">

View File

@ -1,5 +1,6 @@
import axios, { AxiosRequestHeaders } from "axios" import axios, { AxiosRequestHeaders } from "axios"
import { mutate } from "swr" import { mutate } from "swr"
import { WorkspaceBuildTransition } from "./types"
import * as TypesGen from "./typesGenerated" import * as TypesGen from "./typesGenerated"
const CONTENT_TYPE_JSON: AxiosRequestHeaders = { const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
@ -132,6 +133,21 @@ export const getWorkspaceResources = async (workspaceBuildID: string): Promise<T
return response.data return response.data
} }
const postWorkspaceBuild =
(transition: WorkspaceBuildTransition) =>
async (workspaceId: string, template_version_id?: string): Promise<TypesGen.WorkspaceBuild> => {
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<TypesGen.User> => { export const createUser = async (user: TypesGen.CreateUserRequest): Promise<TypesGen.User> => {
const response = await axios.post<TypesGen.User>("/api/v2/users", user) const response = await axios.post<TypesGen.User>("/api/v2/users", user)
return response.data return response.data

View File

@ -10,3 +10,5 @@ export interface ReconnectingPTYRequest {
readonly height?: number readonly height?: number
readonly width?: number readonly width?: number
} }
export type WorkspaceBuildTransition = "start" | "stop" | "delete"

View File

@ -1,6 +1,7 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react" import { Story } from "@storybook/react"
import React from "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" import { Workspace, WorkspaceProps } from "./Workspace"
export default { export default {
@ -11,9 +12,43 @@ export default {
const Template: Story<WorkspaceProps> = (args) => <Workspace {...args} /> const Template: Story<WorkspaceProps> = (args) => <Workspace {...args} />
export const Example = Template.bind({}) export const Started = Template.bind({})
Example.args = { Started.args = {
organization: MockOrganization, organization: MockOrganization,
template: MockTemplate, template: MockTemplate,
workspace: MockWorkspace, 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 }

View File

@ -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(<Workspace organization={MockOrganization} template={MockTemplate} workspace={MockWorkspace} />)
// Then
const element = await screen.findByText(MockWorkspace.name)
expect(element).toBeDefined()
})
})

View File

@ -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 { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography" import Typography from "@material-ui/core/Typography"
import CloudCircleIcon from "@material-ui/icons/CloudCircle"
import React from "react" import React from "react"
import { Link } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage"
import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule"
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
import * as Constants from "./constants" import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar"
export interface WorkspaceProps { export interface WorkspaceProps {
organization: TypesGen.Organization organization?: TypesGen.Organization
workspace: TypesGen.Workspace 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 * Workspace is the top-level component for viewing an individual workspace
*/ */
export const Workspace: React.FC<WorkspaceProps> = ({ organization, template, workspace }) => { export const Workspace: React.FC<WorkspaceProps> = ({
organization,
template,
workspace,
handleStart,
handleStop,
handleRetry,
handleUpdate,
workspaceStatus,
}) => {
const styles = useStyles() const styles = useStyles()
return ( return (
<div className={styles.root}> <div className={styles.root}>
<div className={styles.vertical}> <div className={styles.vertical}>
<WorkspaceHeader organization={organization} template={template} workspace={workspace} /> <WorkspaceStatusBar
organization={organization}
template={template}
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleRetry={handleRetry}
handleUpdate={handleUpdate}
workspaceStatus={workspaceStatus}
/>
<div className={styles.horizontal}> <div className={styles.horizontal}>
<div className={styles.sidebarContainer}> <div className={styles.sidebarContainer}>
<WorkspaceSection title="Applications"> <WorkspaceSection title="Applications">
@ -55,40 +75,6 @@ export const Workspace: React.FC<WorkspaceProps> = ({ organization, template, wo
) )
} }
/**
* Component for the header at the top of the workspace page
*/
export const WorkspaceHeader: React.FC<WorkspaceProps> = ({ organization, template, workspace }) => {
const styles = useStyles()
const templateLink = `/templates/${organization.name}/${template.name}`
return (
<Paper elevation={0} className={styles.section}>
<div className={styles.horizontal}>
<WorkspaceHeroIcon />
<div className={styles.vertical}>
<Typography variant="h4">{workspace.name}</Typography>
<Typography variant="body2" color="textSecondary">
<Link to={templateLink}>{template.name}</Link>
</Typography>
</div>
</div>
</Paper>
)
}
/**
* Component to render the 'Hero Icon' in the header of a workspace
*/
export const WorkspaceHeroIcon: React.FC = () => {
return (
<Box mr="1em">
<CloudCircleIcon width={Constants.TitleIconSize} height={Constants.TitleIconSize} />
</Box>
)
}
/** /**
* Temporary placeholder component until we have the sections implemented * Temporary placeholder component until we have the sections implemented
* Can be removed once the Workspace page has all the necessary sections * 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 { return {
root: { root: {
display: "flex", display: "flex",
@ -115,12 +101,6 @@ export const useStyles = makeStyles((theme) => {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}, },
section: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: Constants.CardRadius,
padding: Constants.CardPadding,
margin: theme.spacing(1),
},
sidebarContainer: { sidebarContainer: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -129,9 +109,5 @@ export const useStyles = makeStyles((theme) => {
timelineContainer: { timelineContainer: {
flex: 1, flex: 1,
}, },
icon: {
width: Constants.TitleIconSize,
height: Constants.TitleIconSize,
},
} }
}) })

View File

@ -1,3 +0,0 @@
export const TitleIconSize = 48
export const CardRadius = 8
export const CardPadding = 20

View File

@ -2,10 +2,10 @@ import Paper from "@material-ui/core/Paper"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography" import Typography from "@material-ui/core/Typography"
import React from "react" import React from "react"
import { CardPadding, CardRadius } from "../Workspace/constants" import { CardPadding, CardRadius } from "../../theme/constants"
export interface WorkspaceSectionProps { export interface WorkspaceSectionProps {
title: string title?: string
} }
export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, children }) => { export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, children }) => {
@ -13,11 +13,13 @@ export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, child
return ( return (
<Paper elevation={0} className={styles.root}> <Paper elevation={0} className={styles.root}>
<div className={styles.headerContainer}> {title && (
<div className={styles.header}> <div className={styles.headerContainer}>
<Typography variant="h6">{title}</Typography> <div className={styles.header}>
<Typography variant="h6">{title}</Typography>
</div>
</div> </div>
</div> )}
<div className={styles.contents}>{children}</div> <div className={styles.contents}>{children}</div>
</Paper> </Paper>

View File

@ -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<WorkspaceStatusBarProps> = ({
organization,
template,
workspace,
handleStart,
handleStop,
handleRetry,
handleUpdate,
workspaceStatus,
}) => {
const styles = useStyles()
const templateLink = `/templates/${organization?.name}/${template?.name}`
const settingsLink = "edit"
return (
<WorkspaceSection>
<Stack spacing={1}>
<div className={combineClasses([styles.horizontal, styles.reverse])}>
<div className={styles.horizontal}>
<Link className={styles.link} to={settingsLink}>
{Language.settings}
</Link>
</div>
{organization && template && (
<Typography variant="body2" color="textSecondary">
Back to{" "}
<Link className={styles.link} to={templateLink}>
{template.name}
</Link>
</Typography>
)}
</div>
<div className={styles.horizontal}>
<div className={styles.horizontal}>
<Typography variant="h4">{workspace.name}</Typography>
<Box className={styles.statusChip} role="status">
{Language[workspaceStatus]}
</Box>
</div>
<div className={styles.horizontal}>
{workspaceStatus === "started" && (
<Button onClick={handleStop} color="primary">
{Language.stop}
</Button>
)}
{workspaceStatus === "stopped" && (
<Button onClick={handleStart} color="primary">
{Language.start}
</Button>
)}
{workspaceStatus === "error" && (
<Button onClick={handleRetry} color="primary">
{Language.retry}
</Button>
)}
{workspace.outdated && canAcceptJobs(workspaceStatus) && (
<Button onClick={handleUpdate} color="primary">
{Language.update}
</Button>
)}
</div>
</div>
</Stack>
</WorkspaceSection>
)
}
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",
},
}
})

View File

@ -1,8 +1,57 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { screen } from "@testing-library/react" import { screen } from "@testing-library/react"
import { rest } from "msw"
import React from "react" 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" 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<Promise<WorkspaceBuild>, [workspaceId: string, templateVersionId?: string | undefined]>
| jest.SpyInstance<Promise<Template>, [templateId: string]>,
) => {
renderWithAuth(<WorkspacePage />, { 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(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
const status = await screen.findByRole("status")
expect(status).toHaveTextContent(label)
}
describe("Workspace Page", () => { describe("Workspace Page", () => {
it("shows a workspace", async () => { it("shows a workspace", async () => {
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
@ -11,4 +60,71 @@ describe("Workspace Page", () => {
expect(workspaceName).toBeDefined() expect(workspaceName).toBeDefined()
expect(templateName).toBeDefined() expect(templateName).toBeDefined()
}) })
it("shows the status of the workspace", async () => {
renderWithAuth(<WorkspacePage />, { 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)
})
}) })

View File

@ -1,4 +1,4 @@
import { useActor } from "@xstate/react" import { useActor, useSelector } from "@xstate/react"
import React, { useContext, useEffect } from "react" import React, { useContext, useEffect } from "react"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
@ -8,6 +8,18 @@ import { Stack } from "../../components/Stack/Stack"
import { Workspace } from "../../components/Workspace/Workspace" import { Workspace } from "../../components/Workspace/Workspace"
import { firstOrItem } from "../../util/array" import { firstOrItem } from "../../util/array"
import { XServiceContext } from "../../xServices/StateContext" 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 = () => { export const WorkspacePage: React.FC = () => {
const { workspace: workspaceQueryParam } = useParams() const { workspace: workspaceQueryParam } = useParams()
@ -17,6 +29,7 @@ export const WorkspacePage: React.FC = () => {
const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService) const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService)
const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError } = const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError } =
workspaceState.context workspaceState.context
const workspaceStatus = useSelector(xServices.workspaceXService, selectWorkspaceStatus)
/** /**
* Get workspace, template, and organization on mount and whenever workspaceId changes. * Get workspace, template, and organization on mount and whenever workspaceId changes.
@ -28,13 +41,22 @@ export const WorkspacePage: React.FC = () => {
if (workspaceState.matches("error")) { if (workspaceState.matches("error")) {
return <ErrorSummary error={getWorkspaceError || getTemplateError || getOrganizationError} /> return <ErrorSummary error={getWorkspaceError || getTemplateError || getOrganizationError} />
} else if (!workspace || !template || !organization) { } else if (!workspace) {
return <FullScreenLoader /> return <FullScreenLoader />
} else { } else {
return ( return (
<Margins> <Margins>
<Stack spacing={4}> <Stack spacing={4}>
<Workspace organization={organization} template={template} workspace={workspace} /> <Workspace
organization={organization}
template={template}
workspace={workspace}
handleStart={() => workspaceSend("START")}
handleStop={() => workspaceSend("STOP")}
handleRetry={() => workspaceSend("RETRY")}
handleUpdate={() => workspaceSend("UPDATE")}
workspaceStatus={workspaceStatus}
/>
</Stack> </Stack>
</Margins> </Margins>
) )

View File

@ -0,0 +1,5 @@
import React from "react"
export const WorkspaceSettingsPage: React.FC = () => {
return <div>Coming soon!</div>
}

View File

@ -71,6 +71,13 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = {
status: "succeeded", 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 = { export const MockTemplate: TypesGen.Template = {
id: "test-template", id: "test-template",
created_at: "", created_at: "",
@ -115,6 +122,16 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
workspace_id: "test-workspace", workspace_id: "test-workspace",
} }
export const MockWorkspaceBuildStop = {
...MockWorkspaceBuild,
transition: "stop",
}
export const MockWorkspaceBuildDelete = {
...MockWorkspaceBuild,
transition: "delete",
}
export const MockWorkspace: TypesGen.Workspace = { export const MockWorkspace: TypesGen.Workspace = {
id: "test-workspace", id: "test-workspace",
name: "Test-Workspace", name: "Test-Workspace",
@ -130,6 +147,31 @@ export const MockWorkspace: TypesGen.Workspace = {
latest_build: MockWorkspaceBuild, 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 = { export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
architecture: "amd64", architecture: "amd64",
created_at: "", created_at: "",

View File

@ -1,4 +1,6 @@
import { rest } from "msw" import { rest } from "msw"
import { WorkspaceBuildTransition } from "../api/types"
import { CreateWorkspaceBuildRequest } from "../api/typesGenerated"
import { permissionsToCheck } from "../xServices/auth/authXService" import { permissionsToCheck } from "../xServices/auth/authXService"
import * as M from "./entities" import * as M from "./entities"
@ -80,6 +82,16 @@ export const handlers = [
rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => { rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => {
return res(ctx.status(200)) 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 // workspace builds
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => { rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => {

View File

@ -9,3 +9,6 @@ export const emptyBoxShadow = "none"
export const navHeight = 56 export const navHeight = 56
export const maxWidth = 1380 export const maxWidth = 1380
export const sidePadding = "50px" export const sidePadding = "50px"
export const TitleIconSize = 48
export const CardRadius = 8
export const CardPadding = 20

View File

@ -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<WorkspaceBuildTransition, WorkspaceStatus> = {
start: "starting",
stop: "stopping",
delete: "deleting",
}
const succeededToStatus: Record<WorkspaceBuildTransition, WorkspaceStatus> = {
start: "started",
stop: "stopped",
delete: "deleted",
}
export const selectWorkspaceStatus = (state: State<WorkspaceContext, WorkspaceEvent>): 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"
}
}

View File

@ -1,17 +1,34 @@
import { assign, createMachine } from "xstate" import { assign, createMachine } from "xstate"
import * as API from "../../api/api" import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated" 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 workspace?: TypesGen.Workspace
template?: TypesGen.Template template?: TypesGen.Template
organization?: TypesGen.Organization organization?: TypesGen.Organization
build?: TypesGen.WorkspaceBuild
getWorkspaceError?: Error | unknown getWorkspaceError?: Error | unknown
getTemplateError?: Error | unknown getTemplateError?: Error | unknown
getOrganizationError?: 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( export const workspaceMachine = createMachine(
{ {
@ -29,23 +46,34 @@ export const workspaceMachine = createMachine(
getOrganization: { getOrganization: {
data: TypesGen.Organization data: TypesGen.Organization
} }
startWorkspace: {
data: TypesGen.WorkspaceBuild
}
stopWorkspace: {
data: TypesGen.WorkspaceBuild
}
refreshWorkspace: {
data: TypesGen.Workspace | undefined
}
}, },
}, },
id: "workspaceState", id: "workspaceState",
initial: "idle", initial: "idle",
on: {
GET_WORKSPACE: "gettingWorkspace",
},
states: { states: {
idle: { idle: {
on: { tags: "loading",
GET_WORKSPACE: "gettingWorkspace",
},
}, },
gettingWorkspace: { gettingWorkspace: {
entry: ["clearGetWorkspaceError", "clearContext"],
invoke: { invoke: {
src: "getWorkspace", src: "getWorkspace",
id: "getWorkspace", id: "getWorkspace",
onDone: { onDone: {
target: "gettingTemplate", target: "ready",
actions: ["assignWorkspace", "clearGetWorkspaceError"], actions: ["assignWorkspace"],
}, },
onError: { onError: {
target: "error", target: "error",
@ -54,35 +82,125 @@ export const workspaceMachine = createMachine(
}, },
tags: "loading", tags: "loading",
}, },
gettingTemplate: { ready: {
invoke: { type: "parallel",
src: "getTemplate", states: {
id: "getTemplate", // We poll the workspace consistently to know if it becomes outdated and to update build status
onDone: { pollingWorkspace: {
target: "gettingOrganization", initial: "refreshingWorkspace",
actions: ["assignTemplate", "clearGetTemplateError"], states: {
refreshingWorkspace: {
entry: "clearRefreshWorkspaceError",
invoke: {
id: "refreshWorkspace",
src: "refreshWorkspace",
onDone: { target: "waiting", actions: "assignWorkspace" },
onError: { target: "waiting", actions: "assignRefreshWorkspaceError" },
},
},
waiting: {
after: {
1000: "refreshingWorkspace",
},
},
},
}, },
onError: { breadcrumb: {
target: "error", initial: "gettingTemplate",
actions: "assignGetTemplateError", 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: {},
},
},
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",
},
gettingOrganization: {
invoke: {
src: "getOrganization",
id: "getOrganization",
onDone: {
target: "idle",
actions: ["assignOrganization", "clearGetOrganizationError"],
},
onError: {
target: "error",
actions: "assignGetOrganizationError",
},
},
tags: "loading",
}, },
error: { error: {
on: { on: {
@ -93,6 +211,14 @@ export const workspaceMachine = createMachine(
}, },
{ {
actions: { 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({ assignWorkspace: assign({
workspace: (_, event) => event.data, workspace: (_, event) => event.data,
}), }),
@ -114,6 +240,43 @@ export const workspaceMachine = createMachine(
getOrganizationError: (_, event) => event.data, getOrganizationError: (_, event) => event.data,
}), }),
clearGetOrganizationError: (context) => assign({ ...context, getOrganizationError: undefined }), 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: { services: {
getWorkspace: async (_, event) => { getWorkspace: async (_, event) => {
@ -133,6 +296,27 @@ export const workspaceMachine = createMachine(
throw Error("Cannot get organization without template") 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")
}
},
}, },
}, },
) )