mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add timeline in the workspace page (#1533)
This commit is contained in:
@@ -221,3 +221,8 @@ export const regenerateUserSSHKey = async (userId = "me"): Promise<TypesGen.GitS
|
||||
const response = await axios.put<TypesGen.GitSSHKey>(`/api/v2/users/${userId}/gitsshkey`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspaceBuilds = async (workspaceId: string): Promise<TypesGen.WorkspaceBuild[]> => {
|
||||
const response = await axios.get<TypesGen.WorkspaceBuild[]>(`/api/v2/workspaces/${workspaceId}/builds`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import React from "react"
|
||||
import { MockBuilds } from "../../testHelpers/entities"
|
||||
import { BuildsTable, BuildsTableProps } from "./BuildsTable"
|
||||
|
||||
export default {
|
||||
title: "components/BuildsTable",
|
||||
component: BuildsTable,
|
||||
} as ComponentMeta<typeof BuildsTable>
|
||||
|
||||
const Template: Story<BuildsTableProps> = (args) => <BuildsTable {...args} />
|
||||
|
||||
export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
builds: MockBuilds,
|
||||
}
|
||||
|
||||
export const Empty = Template.bind({})
|
||||
Empty.args = {
|
||||
builds: [],
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Box from "@material-ui/core/Box"
|
||||
import { Theme } from "@material-ui/core/styles"
|
||||
import Table from "@material-ui/core/Table"
|
||||
import TableBody from "@material-ui/core/TableBody"
|
||||
import TableCell from "@material-ui/core/TableCell"
|
||||
import TableHead from "@material-ui/core/TableHead"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import useTheme from "@material-ui/styles/useTheme"
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import React from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { getDisplayStatus } from "../../util/workspace"
|
||||
import { EmptyState } from "../EmptyState/EmptyState"
|
||||
import { TableLoader } from "../TableLoader/TableLoader"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(duration)
|
||||
|
||||
export const Language = {
|
||||
emptyMessage: "No builds found",
|
||||
inProgressLabel: "In progress",
|
||||
actionLabel: "Action",
|
||||
durationLabel: "Duration",
|
||||
startedAtLabel: "Started at",
|
||||
statusLabel: "Status",
|
||||
}
|
||||
|
||||
const getDurationInSeconds = (build: TypesGen.WorkspaceBuild) => {
|
||||
let display = Language.inProgressLabel
|
||||
|
||||
if (build.job.started_at && build.job.completed_at) {
|
||||
const startedAt = dayjs(build.job.started_at)
|
||||
const completedAt = dayjs(build.job.completed_at)
|
||||
const diff = completedAt.diff(startedAt, "seconds")
|
||||
display = `${diff} seconds`
|
||||
}
|
||||
|
||||
return display
|
||||
}
|
||||
|
||||
export interface BuildsTableProps {
|
||||
builds?: TypesGen.WorkspaceBuild[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const BuildsTable: React.FC<BuildsTableProps> = ({ builds, className }) => {
|
||||
const isLoading = !builds
|
||||
const theme: Theme = useTheme()
|
||||
|
||||
return (
|
||||
<Table className={className}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="20%">{Language.actionLabel}</TableCell>
|
||||
<TableCell width="20%">{Language.durationLabel}</TableCell>
|
||||
<TableCell width="40%">{Language.startedAtLabel}</TableCell>
|
||||
<TableCell width="20%">{Language.statusLabel}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader />}
|
||||
{builds &&
|
||||
builds.map((b) => {
|
||||
const status = getDisplayStatus(theme, b)
|
||||
const duration = getDurationInSeconds(b)
|
||||
|
||||
return (
|
||||
<TableRow key={b.id} data-testid={`build-${b.id}`}>
|
||||
<TableCell>{b.transition}</TableCell>
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>{duration}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>{new Date(b.created_at).toLocaleString()}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span style={{ color: status.color }}>{status.status}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
|
||||
{builds && builds.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<Box p={4}>
|
||||
<EmptyState message={Language.emptyMessage} />
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Typography from "@material-ui/core/Typography"
|
||||
import React from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { WorkspaceStatus } from "../../util/workspace"
|
||||
import { BuildsTable } from "../BuildsTable/BuildsTable"
|
||||
import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule"
|
||||
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
|
||||
import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar"
|
||||
@@ -16,6 +17,7 @@ export interface WorkspaceProps {
|
||||
handleRetry: () => void
|
||||
handleUpdate: () => void
|
||||
workspaceStatus: WorkspaceStatus
|
||||
builds?: TypesGen.WorkspaceBuild[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,6 +30,7 @@ export const Workspace: React.FC<WorkspaceProps> = ({
|
||||
handleRetry,
|
||||
handleUpdate,
|
||||
workspaceStatus,
|
||||
builds,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
||||
@@ -56,13 +59,8 @@ export const Workspace: React.FC<WorkspaceProps> = ({
|
||||
</WorkspaceSection>
|
||||
</div>
|
||||
<div className={styles.timelineContainer}>
|
||||
<WorkspaceSection title="Timeline">
|
||||
<div
|
||||
className={styles.vertical}
|
||||
style={{ justifyContent: "center", alignItems: "center", height: "300px" }}
|
||||
>
|
||||
<Placeholder />
|
||||
</div>
|
||||
<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
|
||||
<BuildsTable builds={builds} className={styles.timelineTable} />
|
||||
</WorkspaceSection>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,5 +103,11 @@ export const useStyles = makeStyles(() => {
|
||||
timelineContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
timelineContents: {
|
||||
margin: 0,
|
||||
},
|
||||
timelineTable: {
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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 React, { HTMLProps } from "react"
|
||||
import { CardPadding, CardRadius } from "../../theme/constants"
|
||||
import { combineClasses } from "../../util/combineClasses"
|
||||
|
||||
export interface WorkspaceSectionProps {
|
||||
title?: string
|
||||
contentsProps?: HTMLProps<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, children }) => {
|
||||
export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, children, contentsProps }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
@@ -21,7 +23,9 @@ export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, child
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.contents}>{children}</div>
|
||||
<div {...contentsProps} className={combineClasses([styles.contents, contentsProps?.className])}>
|
||||
{children}
|
||||
</div>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
import { screen } from "@testing-library/react"
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react"
|
||||
import { rest } from "msw"
|
||||
import React from "react"
|
||||
import * as api from "../../api/api"
|
||||
import { Template, Workspace, WorkspaceBuild } from "../../api/typesGenerated"
|
||||
import { Workspace } from "../../api/typesGenerated"
|
||||
import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar"
|
||||
import {
|
||||
MockBuilds,
|
||||
MockCancelingWorkspace,
|
||||
MockDeletedWorkspace,
|
||||
MockDeletingWorkspace,
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
import { server } from "../../testHelpers/server"
|
||||
import { WorkspacePage } from "./WorkspacePage"
|
||||
|
||||
// It renders the workspace page and waits for it be loaded
|
||||
const renderWorkspacePage = async () => {
|
||||
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
|
||||
await screen.findByText(MockWorkspace.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -29,16 +35,11 @@ import { WorkspacePage } from "./WorkspacePage"
|
||||
* 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 testButton = async (label: string, actionMock: jest.SpyInstance) => {
|
||||
await renderWorkspacePage()
|
||||
const button = await screen.findByText(label)
|
||||
button.click()
|
||||
expect(mock).toHaveBeenCalled()
|
||||
await waitFor(() => fireEvent.click(button))
|
||||
expect(actionMock).toBeCalled()
|
||||
}
|
||||
|
||||
const testStatus = async (mock: Workspace, label: string) => {
|
||||
@@ -47,82 +48,118 @@ const testStatus = async (mock: Workspace, label: string) => {
|
||||
return res(ctx.status(200), ctx.json(mock))
|
||||
}),
|
||||
)
|
||||
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
|
||||
await renderWorkspacePage()
|
||||
const status = await screen.findByRole("status")
|
||||
expect(status).toHaveTextContent(label)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
describe("Workspace Page", () => {
|
||||
it("shows a workspace", async () => {
|
||||
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
|
||||
const workspaceName = await screen.findByText(MockWorkspace.name)
|
||||
await renderWorkspacePage()
|
||||
const workspaceName = screen.getByText(MockWorkspace.name)
|
||||
expect(workspaceName).toBeDefined()
|
||||
})
|
||||
it("shows the status of the workspace", async () => {
|
||||
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
|
||||
const status = await screen.findByRole("status")
|
||||
await renderWorkspacePage()
|
||||
const status = screen.getByRole("status")
|
||||
expect(status).toHaveTextContent("Running")
|
||||
})
|
||||
it("requests a stop job when the user presses Stop", async () => {
|
||||
const stopWorkspaceMock = jest.spyOn(api, "stopWorkspace").mockResolvedValueOnce(MockWorkspaceBuild)
|
||||
await testButton(Language.stop, stopWorkspaceMock)
|
||||
})
|
||||
it("requests a start job when the user presses Start", async () => {
|
||||
server.use(
|
||||
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(MockStoppedWorkspace))
|
||||
}),
|
||||
)
|
||||
const startWorkspaceMock = jest
|
||||
.spyOn(api, "startWorkspace")
|
||||
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
|
||||
await testButton(Language.start, startWorkspaceMock)
|
||||
})
|
||||
it("requests a start job when the user presses Retry after trying to start", async () => {
|
||||
// Use a workspace that failed during start
|
||||
server.use(
|
||||
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...MockFailedWorkspace,
|
||||
latest_build: {
|
||||
...MockFailedWorkspace.latest_build,
|
||||
transition: "start",
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const startWorkSpaceMock = jest.spyOn(api, "startWorkspace").mockResolvedValueOnce(MockWorkspaceBuild)
|
||||
await testButton(Language.retry, startWorkSpaceMock)
|
||||
})
|
||||
it("requests a stop job when the user presses Retry after trying to stop", async () => {
|
||||
// Use a workspace that failed during stop
|
||||
server.use(
|
||||
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...MockFailedWorkspace,
|
||||
latest_build: {
|
||||
...MockFailedWorkspace.latest_build,
|
||||
transition: "stop",
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
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)
|
||||
})
|
||||
await testButton(Language.retry, stopWorkspaceMock)
|
||||
})
|
||||
it("requests a template when the user presses Update", async () => {
|
||||
const getTemplateMock = jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
|
||||
server.use(
|
||||
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(MockOutdatedWorkspace))
|
||||
}),
|
||||
)
|
||||
await testButton(Language.update, getTemplateMock)
|
||||
})
|
||||
it("shows the Stopping status when the workspace is stopping", async () => {
|
||||
await testStatus(MockStoppingWorkspace, Language.stopping)
|
||||
})
|
||||
it("shows the Stopped status when the workspace is stopped", async () => {
|
||||
testStatus(MockStoppedWorkspace, Language.stopped)
|
||||
await testStatus(MockStoppedWorkspace, Language.stopped)
|
||||
})
|
||||
it("shows the Building status when the workspace is starting", async () => {
|
||||
testStatus(MockStartingWorkspace, Language.starting)
|
||||
await testStatus(MockStartingWorkspace, Language.starting)
|
||||
})
|
||||
it("shows the Running status when the workspace is started", async () => {
|
||||
testStatus(MockWorkspace, Language.started)
|
||||
await testStatus(MockWorkspace, Language.started)
|
||||
})
|
||||
it("shows the Error status when the workspace is failed or canceled", async () => {
|
||||
testStatus(MockFailedWorkspace, Language.error)
|
||||
await testStatus(MockFailedWorkspace, Language.error)
|
||||
})
|
||||
it("shows the Loading status when the workspace is canceling", async () => {
|
||||
testStatus(MockCancelingWorkspace, Language.canceling)
|
||||
await testStatus(MockCancelingWorkspace, Language.canceling)
|
||||
})
|
||||
it("shows the Deleting status when the workspace is deleting", async () => {
|
||||
testStatus(MockDeletingWorkspace, Language.canceling)
|
||||
await testStatus(MockDeletingWorkspace, Language.deleting)
|
||||
})
|
||||
it("shows the Deleted status when the workspace is deleted", async () => {
|
||||
testStatus(MockDeletedWorkspace, Language.canceling)
|
||||
await testStatus(MockDeletedWorkspace, Language.deleted)
|
||||
})
|
||||
it("shows the timeline build", async () => {
|
||||
await renderWorkspacePage()
|
||||
const table = await screen.findByRole("table")
|
||||
const rows = table.querySelectorAll("tbody > tr")
|
||||
expect(rows).toHaveLength(MockBuilds.length)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ export const WorkspacePage: React.FC = () => {
|
||||
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService)
|
||||
const { workspace, getWorkspaceError, getTemplateError, getOrganizationError } = workspaceState.context
|
||||
const { workspace, getWorkspaceError, getTemplateError, getOrganizationError, builds } = workspaceState.context
|
||||
const workspaceStatus = useSelector(xServices.workspaceXService, (state) => {
|
||||
return getWorkspaceStatus(state.context.workspace?.latest_build)
|
||||
})
|
||||
@@ -44,6 +44,7 @@ export const WorkspacePage: React.FC = () => {
|
||||
handleRetry={() => workspaceSend("RETRY")}
|
||||
handleUpdate={() => workspaceSend("UPDATE")}
|
||||
workspaceStatus={workspaceStatus}
|
||||
builds={builds}
|
||||
/>
|
||||
</Stack>
|
||||
</Margins>
|
||||
|
||||
@@ -14,11 +14,10 @@ import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import React from "react"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { WorkspaceBuild } from "../../api/typesGenerated"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
import { Stack } from "../../components/Stack/Stack"
|
||||
import { firstLetter } from "../../util/firstLetter"
|
||||
import { getWorkspaceStatus } from "../../util/workspace"
|
||||
import { getDisplayStatus } from "../../util/workspace"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
@@ -68,7 +67,7 @@ export const WorkspacesPageView: React.FC<WorkspacesPageViewProps> = (props) =>
|
||||
</TableRow>
|
||||
)}
|
||||
{props.workspaces?.map((workspace) => {
|
||||
const status = getStatus(theme, workspace.latest_build)
|
||||
const status = getDisplayStatus(theme, workspace.latest_build)
|
||||
return (
|
||||
<TableRow key={workspace.id} className={styles.workspaceRow}>
|
||||
<TableCell>
|
||||
@@ -108,74 +107,6 @@ export const WorkspacesPageView: React.FC<WorkspacesPageViewProps> = (props) =>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatus = (
|
||||
theme: Theme,
|
||||
build: WorkspaceBuild,
|
||||
): {
|
||||
color: string
|
||||
status: string
|
||||
} => {
|
||||
const status = getWorkspaceStatus(build)
|
||||
switch (status) {
|
||||
case undefined:
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "Loading...",
|
||||
}
|
||||
case "started":
|
||||
return {
|
||||
color: theme.palette.success.main,
|
||||
status: "⦿ Running",
|
||||
}
|
||||
case "starting":
|
||||
return {
|
||||
color: theme.palette.success.main,
|
||||
status: "⦿ Starting",
|
||||
}
|
||||
case "stopping":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Stopping",
|
||||
}
|
||||
case "stopped":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Stopped",
|
||||
}
|
||||
case "deleting":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "⦸ Deleting",
|
||||
}
|
||||
case "deleted":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "⦸ Deleted",
|
||||
}
|
||||
case "canceling":
|
||||
return {
|
||||
color: theme.palette.warning.light,
|
||||
status: "◍ Canceling",
|
||||
}
|
||||
case "canceled":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Canceled",
|
||||
}
|
||||
case "error":
|
||||
return {
|
||||
color: theme.palette.error.main,
|
||||
status: "ⓧ Failed",
|
||||
}
|
||||
case "queued":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Queued",
|
||||
}
|
||||
}
|
||||
throw new Error("unknown status " + status)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
actions: {
|
||||
marginTop: theme.spacing(3),
|
||||
|
||||
@@ -113,26 +113,30 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
|
||||
after_id: "",
|
||||
before_id: "",
|
||||
created_at: new Date().toString(),
|
||||
id: "test-workspace-build",
|
||||
id: "1",
|
||||
initiator_id: "",
|
||||
job: MockProvisionerJob,
|
||||
name: "a-workspace-build",
|
||||
template_version_id: "",
|
||||
transition: "start",
|
||||
updated_at: "",
|
||||
updated_at: "2022-05-17T17:39:01.382927298Z",
|
||||
workspace_id: "test-workspace",
|
||||
}
|
||||
|
||||
export const MockWorkspaceBuildStop = {
|
||||
...MockWorkspaceBuild,
|
||||
id: "2",
|
||||
transition: "stop",
|
||||
}
|
||||
|
||||
export const MockWorkspaceBuildDelete = {
|
||||
...MockWorkspaceBuild,
|
||||
id: "3",
|
||||
transition: "delete",
|
||||
}
|
||||
|
||||
export const MockBuilds = [MockWorkspaceBuild, MockWorkspaceBuildStop, MockWorkspaceBuildDelete]
|
||||
|
||||
export const MockWorkspace: TypesGen.Workspace = {
|
||||
id: "test-workspace",
|
||||
name: "Test-Workspace",
|
||||
|
||||
@@ -98,6 +98,9 @@ export const handlers = [
|
||||
const result = transitionToBuild[transition as WorkspaceBuildTransition]
|
||||
return res(ctx.status(200), ctx.json(result))
|
||||
}),
|
||||
rest.get("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockBuilds))
|
||||
}),
|
||||
|
||||
// workspace builds
|
||||
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Theme } from "@material-ui/core/styles"
|
||||
import { WorkspaceBuildTransition } from "../api/types"
|
||||
import { WorkspaceBuild } from "../api/typesGenerated"
|
||||
|
||||
@@ -47,3 +48,71 @@ export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceSt
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
export const getDisplayStatus = (
|
||||
theme: Theme,
|
||||
build: WorkspaceBuild,
|
||||
): {
|
||||
color: string
|
||||
status: string
|
||||
} => {
|
||||
const status = getWorkspaceStatus(build)
|
||||
switch (status) {
|
||||
case undefined:
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "Loading...",
|
||||
}
|
||||
case "started":
|
||||
return {
|
||||
color: theme.palette.success.main,
|
||||
status: "⦿ Running",
|
||||
}
|
||||
case "starting":
|
||||
return {
|
||||
color: theme.palette.success.main,
|
||||
status: "⦿ Starting",
|
||||
}
|
||||
case "stopping":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Stopping",
|
||||
}
|
||||
case "stopped":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Stopped",
|
||||
}
|
||||
case "deleting":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "⦸ Deleting",
|
||||
}
|
||||
case "deleted":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "⦸ Deleted",
|
||||
}
|
||||
case "canceling":
|
||||
return {
|
||||
color: theme.palette.warning.light,
|
||||
status: "◍ Canceling",
|
||||
}
|
||||
case "canceled":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Canceled",
|
||||
}
|
||||
case "error":
|
||||
return {
|
||||
color: theme.palette.error.main,
|
||||
status: "ⓧ Failed",
|
||||
}
|
||||
case "queued":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: "◍ Queued",
|
||||
}
|
||||
}
|
||||
throw new Error("unknown status " + status)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { assign, createMachine } from "xstate"
|
||||
import { assign, createMachine, send } from "xstate"
|
||||
import { pure } from "xstate/lib/actions"
|
||||
import * as API from "../../api/api"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { displayError } from "../../components/GlobalSnackbar/utils"
|
||||
|
||||
const latestBuild = (builds: TypesGen.WorkspaceBuild[]) => {
|
||||
// Cloning builds to not change the origin object with the sort()
|
||||
return [...builds].sort((a, b) => {
|
||||
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
})[0]
|
||||
}
|
||||
|
||||
const Language = {
|
||||
refreshTemplateError: "Error updating workspace: latest template could not be fetched.",
|
||||
buildError: "Workspace action failed.",
|
||||
@@ -21,6 +29,10 @@ export interface WorkspaceContext {
|
||||
// these are separate from getX errors because they don't make the page unusable
|
||||
refreshWorkspaceError: Error | unknown
|
||||
refreshTemplateError: Error | unknown
|
||||
// Builds
|
||||
builds?: TypesGen.WorkspaceBuild[]
|
||||
getBuildsError?: Error | unknown
|
||||
loadMoreBuildsError?: Error | unknown
|
||||
}
|
||||
|
||||
export type WorkspaceEvent =
|
||||
@@ -29,6 +41,8 @@ export type WorkspaceEvent =
|
||||
| { type: "STOP" }
|
||||
| { type: "RETRY" }
|
||||
| { type: "UPDATE" }
|
||||
| { type: "LOAD_MORE_BUILDS" }
|
||||
| { type: "REFRESH_TIMELINE" }
|
||||
|
||||
export const workspaceMachine = createMachine(
|
||||
{
|
||||
@@ -55,6 +69,12 @@ export const workspaceMachine = createMachine(
|
||||
refreshWorkspace: {
|
||||
data: TypesGen.Workspace | undefined
|
||||
}
|
||||
getBuilds: {
|
||||
data: TypesGen.WorkspaceBuild[]
|
||||
}
|
||||
loadMoreBuilds: {
|
||||
data: TypesGen.WorkspaceBuild[]
|
||||
}
|
||||
},
|
||||
},
|
||||
id: "workspaceState",
|
||||
@@ -94,7 +114,7 @@ export const workspaceMachine = createMachine(
|
||||
invoke: {
|
||||
id: "refreshWorkspace",
|
||||
src: "refreshWorkspace",
|
||||
onDone: { target: "waiting", actions: "assignWorkspace" },
|
||||
onDone: { target: "waiting", actions: ["refreshTimeline", "assignWorkspace"] },
|
||||
onError: { target: "waiting", actions: "assignRefreshWorkspaceError" },
|
||||
},
|
||||
},
|
||||
@@ -160,7 +180,7 @@ export const workspaceMachine = createMachine(
|
||||
src: "startWorkspace",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: "assignBuild",
|
||||
actions: ["assignBuild", "refreshTimeline"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
@@ -175,7 +195,7 @@ export const workspaceMachine = createMachine(
|
||||
src: "stopWorkspace",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: "assignBuild",
|
||||
actions: ["assignBuild", "refreshTimeline"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
@@ -200,6 +220,55 @@ export const workspaceMachine = createMachine(
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
timeline: {
|
||||
initial: "gettingBuilds",
|
||||
states: {
|
||||
idle: {},
|
||||
gettingBuilds: {
|
||||
entry: "clearGetBuildsError",
|
||||
invoke: {
|
||||
src: "getBuilds",
|
||||
onDone: {
|
||||
actions: ["assignBuilds"],
|
||||
target: "loadedBuilds",
|
||||
},
|
||||
onError: {
|
||||
actions: ["assignGetBuildsError"],
|
||||
target: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
loadedBuilds: {
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
LOAD_MORE_BUILDS: {
|
||||
target: "loadingMoreBuilds",
|
||||
cond: "hasMoreBuilds",
|
||||
},
|
||||
REFRESH_TIMELINE: "#workspaceState.ready.timeline.gettingBuilds",
|
||||
},
|
||||
},
|
||||
loadingMoreBuilds: {
|
||||
entry: "clearLoadMoreBuildsError",
|
||||
invoke: {
|
||||
src: "loadMoreBuilds",
|
||||
onDone: {
|
||||
actions: ["assignNewBuilds"],
|
||||
target: "idle",
|
||||
},
|
||||
onError: {
|
||||
actions: ["assignLoadMoreBuildsError"],
|
||||
target: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
@@ -274,9 +343,54 @@ export const workspaceMachine = createMachine(
|
||||
assign({
|
||||
refreshTemplateError: undefined,
|
||||
}),
|
||||
// Timeline
|
||||
assignBuilds: assign({
|
||||
builds: (_, event) => event.data,
|
||||
}),
|
||||
assignGetBuildsError: assign({
|
||||
getBuildsError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetBuildsError: assign({
|
||||
getBuildsError: (_) => undefined,
|
||||
}),
|
||||
assignNewBuilds: assign({
|
||||
builds: (context, event) => {
|
||||
const oldBuilds = context.builds
|
||||
|
||||
if (!oldBuilds) {
|
||||
throw new Error("Builds not loaded")
|
||||
}
|
||||
|
||||
return [...oldBuilds, ...event.data]
|
||||
},
|
||||
}),
|
||||
assignLoadMoreBuildsError: assign({
|
||||
loadMoreBuildsError: (_, event) => event.data,
|
||||
}),
|
||||
clearLoadMoreBuildsError: assign({
|
||||
loadMoreBuildsError: (_) => undefined,
|
||||
}),
|
||||
refreshTimeline: pure((context, event) => {
|
||||
// No need to refresh the timeline if it is not loaded
|
||||
if (!context.builds) {
|
||||
return
|
||||
}
|
||||
// When it is a refresh workspace event, we want to check if the latest
|
||||
// build was updated to not over fetch the builds
|
||||
if (event.type === "done.invoke.refreshWorkspace") {
|
||||
const latestBuildInTimeline = latestBuild(context.builds)
|
||||
const isUpdated = event.data?.latest_build.updated_at !== latestBuildInTimeline.updated_at
|
||||
if (isUpdated) {
|
||||
return send({ type: "REFRESH_TIMELINE" })
|
||||
}
|
||||
} else {
|
||||
return send({ type: "REFRESH_TIMELINE" })
|
||||
}
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
triedToStart: (context) => context.workspace?.latest_build.transition === "start",
|
||||
hasMoreBuilds: (_) => false,
|
||||
},
|
||||
services: {
|
||||
getWorkspace: async (_, event) => {
|
||||
@@ -317,6 +431,20 @@ export const workspaceMachine = createMachine(
|
||||
throw Error("Cannot refresh workspace without id")
|
||||
}
|
||||
},
|
||||
getBuilds: async (context) => {
|
||||
if (context.workspace) {
|
||||
return await API.getWorkspaceBuilds(context.workspace.id)
|
||||
} else {
|
||||
throw Error("Cannot refresh workspace without id")
|
||||
}
|
||||
},
|
||||
loadMoreBuilds: async (context) => {
|
||||
if (context.workspace) {
|
||||
return await API.getWorkspaceBuilds(context.workspace.id)
|
||||
} else {
|
||||
throw Error("Cannot refresh workspace without id")
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user