mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00: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:
@ -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">
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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 }
|
||||||
|
@ -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 { 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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -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 { 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>
|
||||||
|
155
site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx
Normal file
155
site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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",
|
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: "",
|
||||||
|
@ -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) => {
|
||||||
|
@ -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
|
||||||
|
37
site/src/xServices/workspace/workspaceSelectors.ts
Normal file
37
site/src/xServices/workspace/workspaceSelectors.ts
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
@ -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")
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user