mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
+19
-8
@@ -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 = () => (
|
||||
</Route>
|
||||
|
||||
<Route path="workspaces">
|
||||
<Route
|
||||
path=":workspace"
|
||||
element={
|
||||
<AuthAndFrame>
|
||||
<WorkspacePage />
|
||||
</AuthAndFrame>
|
||||
}
|
||||
/>
|
||||
<Route path=":workspace">
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<AuthAndFrame>
|
||||
<WorkspacePage />
|
||||
</AuthAndFrame>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="edit"
|
||||
element={
|
||||
<AuthAndFrame>
|
||||
<WorkspaceSettingsPage />
|
||||
</AuthAndFrame>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="users">
|
||||
|
||||
@@ -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<T
|
||||
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> => {
|
||||
const response = await axios.post<TypesGen.User>("/api/v2/users", user)
|
||||
return response.data
|
||||
|
||||
@@ -10,3 +10,5 @@ export interface ReconnectingPTYRequest {
|
||||
readonly height?: number
|
||||
readonly width?: number
|
||||
}
|
||||
|
||||
export type WorkspaceBuildTransition = "start" | "stop" | "delete"
|
||||
|
||||
@@ -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<WorkspaceProps> = (args) => <Workspace {...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 }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<WorkspaceProps> = ({ organization, template, workspace }) => {
|
||||
export const Workspace: React.FC<WorkspaceProps> = ({
|
||||
organization,
|
||||
template,
|
||||
workspace,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleRetry,
|
||||
handleUpdate,
|
||||
workspaceStatus,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<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.sidebarContainer}>
|
||||
<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
|
||||
* 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const TitleIconSize = 48
|
||||
export const CardRadius = 8
|
||||
export const CardPadding = 20
|
||||
@@ -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<WorkspaceSectionProps> = ({ title, children }) => {
|
||||
@@ -13,11 +13,13 @@ export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, child
|
||||
|
||||
return (
|
||||
<Paper elevation={0} className={styles.root}>
|
||||
<div className={styles.headerContainer}>
|
||||
<div className={styles.header}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
{title && (
|
||||
<div className={styles.headerContainer}>
|
||||
<div className={styles.header}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.contents}>{children}</div>
|
||||
</Paper>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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<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", () => {
|
||||
it("shows a workspace", async () => {
|
||||
renderWithAuth(<WorkspacePage />, { 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(<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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 <ErrorSummary error={getWorkspaceError || getTemplateError || getOrganizationError} />
|
||||
} else if (!workspace || !template || !organization) {
|
||||
} else if (!workspace) {
|
||||
return <FullScreenLoader />
|
||||
} else {
|
||||
return (
|
||||
<Margins>
|
||||
<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>
|
||||
</Margins>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import React from "react"
|
||||
|
||||
export const WorkspaceSettingsPage: React.FC = () => {
|
||||
return <div>Coming soon!</div>
|
||||
}
|
||||
@@ -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",
|
||||
@@ -130,6 +147,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: "",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: "assignGetTemplateError",
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
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: {
|
||||
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")
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user