diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx index 0f800d0702f79..683bbeee5fe91 100644 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -34,7 +34,7 @@ export const BuildsTable: React.FC = ({ builds, className }) = const styles = useStyles() return ( - +
{Language.actionLabel} diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx new file mode 100644 index 0000000000000..25448c678bc81 --- /dev/null +++ b/site/src/components/Resources/Resources.tsx @@ -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 = ({ resources, getResourcesError }) => { + const styles = useStyles() + const theme: Theme = useTheme() + + return ( + + {getResourcesError ? ( + { getResourcesError } + ) : ( +
+ + + {Language.resourceLabel} + {Language.agentLabel} + {Language.statusLabel} + + + + {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 ( + + {resource.name} + + + ) + } + + return ( + + {/* 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 && ( + + {resource.name} + + )} + + + {agent.name} + + + + {getDisplayAgentStatus(theme, agent).status} + + + + ) + }) + })} + +
+ )} + + ) +} + +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`, + }, +})) diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 55fbcc672c08e..9ad361fb51fb6 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -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({}) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 4d8b1bd36d28c..8f494ef69673e 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -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 = ({ handleUpdate, workspace, workspaceStatus, + resources, + getResourcesError, builds, }) => { const styles = useStyles() @@ -61,6 +66,7 @@ export const Workspace: React.FC = ({ + diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 57d8d72c9a502..c0f3bab812104 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -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(, { 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) + }) }) }) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index fd0c5fe793938..cf9c9bb5b95db 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -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 + return } else if (!workspace) { return } 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} /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 9c5cb456540fd..3d143aeac9ecd 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 7b42ff65b5c97..1870e7b4bfbdf 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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)) diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index 6c72c72677aea..b78b4b716b7c3 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -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"], + } + } +} diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 782d4f847a459..c8b2fea620b41 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -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)