mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: resources card (#1627)
* Set up table * Format * Hook up api and test - bug assigning resources * Remove debugging code * Format * Remove unnecessary cards * Fix test * Fix assignment * Fix tests * Lint
This commit is contained in:
@@ -34,7 +34,7 @@ export const BuildsTable: React.FC<BuildsTableProps> = ({ builds, className }) =
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<Table className={className}>
|
||||
<Table className={className} data-testid="builds-table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="20%">{Language.actionLabel}</TableCell>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { makeStyles, 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 React from "react"
|
||||
import { WorkspaceResource } from "../../api/typesGenerated"
|
||||
import { getDisplayAgentStatus } from "../../util/workspace"
|
||||
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
|
||||
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
|
||||
|
||||
const Language = {
|
||||
resources: "Resources",
|
||||
resourceLabel: "Resource",
|
||||
agentsLabel: "Agents",
|
||||
agentLabel: "Agent",
|
||||
statusLabel: "Status",
|
||||
}
|
||||
|
||||
interface ResourcesProps {
|
||||
resources?: WorkspaceResource[]
|
||||
getResourcesError?: Error
|
||||
}
|
||||
|
||||
export const Resources: React.FC<ResourcesProps> = ({ resources, getResourcesError }) => {
|
||||
const styles = useStyles()
|
||||
const theme: Theme = useTheme()
|
||||
|
||||
return (
|
||||
<WorkspaceSection title={Language.resources} contentsProps={{ className: styles.sectionContents }}>
|
||||
{getResourcesError ? (
|
||||
{ getResourcesError }
|
||||
) : (
|
||||
<Table className={styles.table}>
|
||||
<TableHead>
|
||||
<TableHeaderRow>
|
||||
<TableCell>{Language.resourceLabel}</TableCell>
|
||||
<TableCell className={styles.agentColumn}>{Language.agentLabel}</TableCell>
|
||||
<TableCell>{Language.statusLabel}</TableCell>
|
||||
</TableHeaderRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{resources?.map((resource) => {
|
||||
{
|
||||
/* We need to initialize the agents to display the resource */
|
||||
}
|
||||
const agents = resource.agents ?? [null]
|
||||
return agents.map((agent, agentIndex) => {
|
||||
{
|
||||
/* If there is no agent, just display the resource name */
|
||||
}
|
||||
if (!agent) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className={styles.resourceNameCell}>{resource.name}</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={`${resource.id}-${agent.id}`}>
|
||||
{/* We only want to display the name in the first row because we are using rowSpan */}
|
||||
{/* The rowspan should be the same than the number of agents */}
|
||||
{agentIndex === 0 && (
|
||||
<TableCell className={styles.resourceNameCell} rowSpan={agents.length}>
|
||||
{resource.name}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell className={styles.agentColumn}>
|
||||
<span style={{ color: theme.palette.text.secondary }}>{agent.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span style={{ color: getDisplayAgentStatus(theme, agent).color }}>
|
||||
{getDisplayAgentStatus(theme, agent).status}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</WorkspaceSection>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
sectionContents: {
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
table: {
|
||||
border: 0,
|
||||
},
|
||||
|
||||
resourceNameCell: {
|
||||
borderRight: `1px solid ${theme.palette.divider}`,
|
||||
},
|
||||
|
||||
// Adds some left spacing
|
||||
agentColumn: {
|
||||
paddingLeft: `${theme.spacing(2)}px !important`,
|
||||
},
|
||||
}))
|
||||
@@ -1,7 +1,12 @@
|
||||
import { action } from "@storybook/addon-actions"
|
||||
import { Story } from "@storybook/react"
|
||||
import React from "react"
|
||||
import { MockOutdatedWorkspace, MockWorkspace } from "../../testHelpers/renderHelpers"
|
||||
import {
|
||||
MockOutdatedWorkspace,
|
||||
MockWorkspace,
|
||||
MockWorkspaceResource,
|
||||
MockWorkspaceResource2,
|
||||
} from "../../testHelpers/renderHelpers"
|
||||
import { Workspace, WorkspaceProps } from "./Workspace"
|
||||
|
||||
export default {
|
||||
@@ -19,6 +24,7 @@ Started.args = {
|
||||
handleStop: action("stop"),
|
||||
handleRetry: action("retry"),
|
||||
workspaceStatus: "started",
|
||||
resources: [MockWorkspaceResource, MockWorkspaceResource2],
|
||||
}
|
||||
|
||||
export const Starting = Template.bind({})
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as TypesGen from "../../api/typesGenerated"
|
||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||
import { WorkspaceStatus } from "../../util/workspace"
|
||||
import { BuildsTable } from "../BuildsTable/BuildsTable"
|
||||
import { Resources } from "../Resources/Resources"
|
||||
import { Stack } from "../Stack/Stack"
|
||||
import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions"
|
||||
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
|
||||
@@ -17,6 +18,8 @@ export interface WorkspaceProps {
|
||||
handleUpdate: () => void
|
||||
workspace: TypesGen.Workspace
|
||||
workspaceStatus: WorkspaceStatus
|
||||
resources?: TypesGen.WorkspaceResource[]
|
||||
getResourcesError?: Error
|
||||
builds?: TypesGen.WorkspaceBuild[]
|
||||
}
|
||||
|
||||
@@ -30,6 +33,8 @@ export const Workspace: React.FC<WorkspaceProps> = ({
|
||||
handleUpdate,
|
||||
workspace,
|
||||
workspaceStatus,
|
||||
resources,
|
||||
getResourcesError,
|
||||
builds,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
@@ -61,6 +66,7 @@ export const Workspace: React.FC<WorkspaceProps> = ({
|
||||
|
||||
<Stack spacing={3}>
|
||||
<WorkspaceStats workspace={workspace} />
|
||||
<Resources resources={resources} getResourcesError={getResourcesError} />
|
||||
<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
|
||||
<BuildsTable builds={builds} className={styles.timelineTable} />
|
||||
</WorkspaceSection>
|
||||
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
MockStoppingWorkspace,
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceAgentDisconnected,
|
||||
MockWorkspaceBuild,
|
||||
renderWithAuth,
|
||||
} from "../../testHelpers/renderHelpers"
|
||||
import { server } from "../../testHelpers/server"
|
||||
import { DisplayStatusLanguage } from "../../util/workspace"
|
||||
import { DisplayAgentStatusLanguage, DisplayStatusLanguage } from "../../util/workspace"
|
||||
import { WorkspacePage } from "./WorkspacePage"
|
||||
|
||||
// It renders the workspace page and waits for it be loaded
|
||||
@@ -157,10 +159,27 @@ describe("Workspace Page", () => {
|
||||
it("shows the Deleted status when the workspace is deleted", async () => {
|
||||
await testStatus(MockDeletedWorkspace, DisplayStatusLanguage.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)
|
||||
|
||||
describe("Timeline", () => {
|
||||
it("shows the timeline build", async () => {
|
||||
await renderWorkspacePage()
|
||||
const table = await screen.findByTestId("builds-table")
|
||||
const rows = table.querySelectorAll("tbody > tr")
|
||||
expect(rows).toHaveLength(MockBuilds.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Resources", () => {
|
||||
it("shows the status of each agent in each resource", async () => {
|
||||
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
|
||||
const agent1Names = await screen.findAllByText(MockWorkspaceAgent.name)
|
||||
expect(agent1Names.length).toEqual(2)
|
||||
const agent2Names = await screen.findAllByText(MockWorkspaceAgentDisconnected.name)
|
||||
expect(agent2Names.length).toEqual(2)
|
||||
const agent1Status = await screen.findAllByText(DisplayAgentStatusLanguage[MockWorkspaceAgent.status])
|
||||
expect(agent1Status.length).toEqual(2)
|
||||
const agent2Status = await screen.findAllByText(DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status])
|
||||
expect(agent2Status.length).toEqual(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useActor, useSelector } from "@xstate/react"
|
||||
import { useActor } from "@xstate/react"
|
||||
import React, { useContext, useEffect } from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
||||
@@ -16,10 +16,8 @@ export const WorkspacePage: React.FC = () => {
|
||||
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService)
|
||||
const { workspace, getWorkspaceError, getTemplateError, getOrganizationError, builds } = workspaceState.context
|
||||
const workspaceStatus = useSelector(xServices.workspaceXService, (state) => {
|
||||
return getWorkspaceStatus(state.context.workspace?.latest_build)
|
||||
})
|
||||
const { workspace, resources, getWorkspaceError, getResourcesError, builds } = workspaceState.context
|
||||
const workspaceStatus = getWorkspaceStatus(workspace?.latest_build)
|
||||
|
||||
/**
|
||||
* Get workspace, template, and organization on mount and whenever workspaceId changes.
|
||||
@@ -30,7 +28,7 @@ export const WorkspacePage: React.FC = () => {
|
||||
}, [workspaceId, workspaceSend])
|
||||
|
||||
if (workspaceState.matches("error")) {
|
||||
return <ErrorSummary error={getWorkspaceError || getTemplateError || getOrganizationError} />
|
||||
return <ErrorSummary error={getWorkspaceError} />
|
||||
} else if (!workspace) {
|
||||
return <FullScreenLoader />
|
||||
} else {
|
||||
@@ -44,6 +42,8 @@ export const WorkspacePage: React.FC = () => {
|
||||
handleRetry={() => workspaceSend("RETRY")}
|
||||
handleUpdate={() => workspaceSend("UPDATE")}
|
||||
workspaceStatus={workspaceStatus}
|
||||
resources={resources}
|
||||
getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined}
|
||||
builds={builds}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -188,8 +188,15 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
|
||||
updated_at: "",
|
||||
}
|
||||
|
||||
export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = {
|
||||
...MockWorkspaceAgent,
|
||||
id: "test-workspace-agent-2",
|
||||
name: "another-workspace-agent",
|
||||
status: "disconnected",
|
||||
}
|
||||
|
||||
export const MockWorkspaceResource: TypesGen.WorkspaceResource = {
|
||||
agents: [MockWorkspaceAgent],
|
||||
agents: [MockWorkspaceAgent, MockWorkspaceAgentDisconnected],
|
||||
created_at: "",
|
||||
id: "test-workspace-resource",
|
||||
job_id: "",
|
||||
@@ -198,6 +205,12 @@ export const MockWorkspaceResource: TypesGen.WorkspaceResource = {
|
||||
workspace_transition: "start",
|
||||
}
|
||||
|
||||
export const MockWorkspaceResource2 = {
|
||||
...MockWorkspaceResource,
|
||||
id: "test-workspace-resource-2",
|
||||
name: "another-workspace-resource",
|
||||
}
|
||||
|
||||
export const MockUserAgent: Types.UserAgent = {
|
||||
browser: "Chrome 99.0.4844",
|
||||
device: "Other",
|
||||
|
||||
@@ -125,7 +125,7 @@ export const handlers = [
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuild))
|
||||
}),
|
||||
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json([M.MockWorkspaceResource]))
|
||||
return res(ctx.status(200), ctx.json([M.MockWorkspaceResource, M.MockWorkspaceResource2]))
|
||||
}),
|
||||
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/logs", (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuildLogs))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Theme } from "@material-ui/core/styles"
|
||||
import dayjs from "dayjs"
|
||||
import { WorkspaceBuildTransition } from "../api/types"
|
||||
import { WorkspaceBuild } from "../api/typesGenerated"
|
||||
import { WorkspaceAgent, WorkspaceBuild } from "../api/typesGenerated"
|
||||
|
||||
export type WorkspaceStatus =
|
||||
| "queued"
|
||||
@@ -148,3 +148,40 @@ export const displayWorkspaceBuildDuration = (build: WorkspaceBuild, inProgressL
|
||||
const duration = getWorkspaceBuildDurationInSeconds(build)
|
||||
return duration ? `${duration} seconds` : inProgressLabel
|
||||
}
|
||||
|
||||
export const DisplayAgentStatusLanguage = {
|
||||
connected: "⦿ Connected",
|
||||
connecting: "⦿ Connecting",
|
||||
disconnected: "◍ Disconnected",
|
||||
}
|
||||
|
||||
export const getDisplayAgentStatus = (
|
||||
theme: Theme,
|
||||
agent: WorkspaceAgent,
|
||||
): {
|
||||
color: string
|
||||
status: string
|
||||
} => {
|
||||
switch (agent.status) {
|
||||
case undefined:
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: DisplayStatusLanguage.loading,
|
||||
}
|
||||
case "connected":
|
||||
return {
|
||||
color: theme.palette.success.main,
|
||||
status: DisplayAgentStatusLanguage["connected"],
|
||||
}
|
||||
case "connecting":
|
||||
return {
|
||||
color: theme.palette.success.main,
|
||||
status: DisplayAgentStatusLanguage["connecting"],
|
||||
}
|
||||
case "disconnected":
|
||||
return {
|
||||
color: theme.palette.text.secondary,
|
||||
status: DisplayAgentStatusLanguage["disconnected"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface WorkspaceContext {
|
||||
template?: TypesGen.Template
|
||||
organization?: TypesGen.Organization
|
||||
build?: TypesGen.WorkspaceBuild
|
||||
resources?: TypesGen.WorkspaceResource[]
|
||||
getWorkspaceError?: Error | unknown
|
||||
getTemplateError?: Error | unknown
|
||||
getOrganizationError?: Error | unknown
|
||||
@@ -29,6 +30,7 @@ export interface WorkspaceContext {
|
||||
// these are separate from getX errors because they don't make the page unusable
|
||||
refreshWorkspaceError: Error | unknown
|
||||
refreshTemplateError: Error | unknown
|
||||
getResourcesError: Error | unknown
|
||||
// Builds
|
||||
builds?: TypesGen.WorkspaceBuild[]
|
||||
getBuildsError?: Error | unknown
|
||||
@@ -69,6 +71,9 @@ export const workspaceMachine = createMachine(
|
||||
refreshWorkspace: {
|
||||
data: TypesGen.Workspace | undefined
|
||||
}
|
||||
getResources: {
|
||||
data: TypesGen.WorkspaceResource[]
|
||||
}
|
||||
getBuilds: {
|
||||
data: TypesGen.WorkspaceBuild[]
|
||||
}
|
||||
@@ -220,6 +225,25 @@ export const workspaceMachine = createMachine(
|
||||
},
|
||||
},
|
||||
},
|
||||
pollingResources: {
|
||||
initial: "gettingResources",
|
||||
states: {
|
||||
gettingResources: {
|
||||
entry: "clearGetResourcesError",
|
||||
invoke: {
|
||||
id: "getResources",
|
||||
src: "getResources",
|
||||
onDone: { target: "waiting", actions: "assignResources" },
|
||||
onError: { target: "waiting", actions: "assignGetResourcesError" },
|
||||
},
|
||||
},
|
||||
waiting: {
|
||||
after: {
|
||||
5000: "gettingResources",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
timeline: {
|
||||
initial: "gettingBuilds",
|
||||
@@ -343,6 +367,17 @@ export const workspaceMachine = createMachine(
|
||||
assign({
|
||||
refreshTemplateError: undefined,
|
||||
}),
|
||||
assignResources: assign({
|
||||
resources: (_, event) => event.data,
|
||||
}),
|
||||
assignGetResourcesError: (_, event) =>
|
||||
assign({
|
||||
getResourcesError: event.data,
|
||||
}),
|
||||
clearGetResourcesError: (_) =>
|
||||
assign({
|
||||
getResourcesError: undefined,
|
||||
}),
|
||||
// Timeline
|
||||
assignBuilds: assign({
|
||||
builds: (_, event) => event.data,
|
||||
@@ -431,6 +466,14 @@ export const workspaceMachine = createMachine(
|
||||
throw Error("Cannot refresh workspace without id")
|
||||
}
|
||||
},
|
||||
getResources: async (context) => {
|
||||
if (context.workspace) {
|
||||
const resources = await API.getWorkspaceResources(context.workspace.latest_build.id)
|
||||
return resources
|
||||
} else {
|
||||
throw Error("Cannot fetch workspace resources without workspace")
|
||||
}
|
||||
},
|
||||
getBuilds: async (context) => {
|
||||
if (context.workspace) {
|
||||
return await API.getWorkspaceBuilds(context.workspace.id)
|
||||
|
||||
Reference in New Issue
Block a user