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:
Presley Pizzo
2022-05-20 18:29:42 +00:00
committed by GitHub
parent c189fc52c1
commit e40c68399d
10 changed files with 250 additions and 17 deletions
@@ -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>
+109
View File
@@ -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>
+14 -1
View File
@@ -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",
+1 -1
View File
@@ -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))
+38 -1
View File
@@ -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)