From 6ce35b4af2091653254118feb358dcc80d0048e1 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 25 Mar 2026 16:54:15 +0000 Subject: [PATCH] fix(site): show accurate health messages in workspace hover menu and status tooltip (#23591) --- .../WorkspaceStatusIndicator.stories.tsx | 25 +- .../WorkspaceStatusIndicator.tsx | 5 +- site/src/modules/workspaces/health.test.ts | 391 ++++++++++++++++++ site/src/modules/workspaces/health.ts | 124 ++++++ site/src/pages/WorkspacePage/Workspace.tsx | 3 +- .../WorkspacePage/WorkspaceAlert.stories.tsx | 94 +---- .../pages/WorkspacePage/WorkspaceAlert.tsx | 79 +--- .../WorkspaceNotifications.stories.tsx | 98 ++++- .../WorkspaceNotifications.tsx | 16 +- 9 files changed, 658 insertions(+), 177 deletions(-) create mode 100644 site/src/modules/workspaces/health.test.ts create mode 100644 site/src/modules/workspaces/health.ts diff --git a/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.stories.tsx b/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.stories.tsx index 61dd592886..04a7fa5cdb 100644 --- a/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.stories.tsx @@ -1,4 +1,8 @@ -import { MockWorkspace } from "testHelpers/entities"; +import { + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceResource, +} from "testHelpers/entities"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Workspace, WorkspaceStatus } from "api/typesGenerated"; import { WorkspaceStatusIndicator } from "./WorkspaceStatusIndicator"; @@ -33,7 +37,24 @@ export const Unhealthy: Story = { ...createWorkspaceWithStatus("running"), health: { healthy: false, - failing_agents: [], + failing_agents: [MockWorkspaceAgent.id], + }, + latest_build: { + ...MockWorkspace.latest_build, + status: "running", + resources: [ + { + ...MockWorkspaceResource, + agents: [ + { + ...MockWorkspaceAgent, + status: "connected", + lifecycle_state: "start_error", + health: { healthy: false }, + }, + ], + }, + ], }, }, }, diff --git a/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx b/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx index 015725681e..dd56839726 100644 --- a/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx +++ b/site/src/modules/workspaces/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx @@ -1,4 +1,5 @@ import type { Workspace } from "api/typesGenerated"; +import { getAgentHealthIssue } from "modules/workspaces/health"; import type React from "react"; import type { FC } from "react"; import { @@ -67,9 +68,7 @@ export const WorkspaceStatusIndicator: FC = ({ {children} - - Your workspace is running but some agents are unhealthy. - + {getAgentHealthIssue(workspace).detail} ); }; diff --git a/site/src/modules/workspaces/health.test.ts b/site/src/modules/workspaces/health.test.ts new file mode 100644 index 0000000000..eba567cb78 --- /dev/null +++ b/site/src/modules/workspaces/health.test.ts @@ -0,0 +1,391 @@ +import { + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceBuild, + MockWorkspaceResource, +} from "testHelpers/entities"; +import type { + Workspace, + WorkspaceAgentLifecycle, + WorkspaceAgentStatus, +} from "api/typesGenerated"; +import { describe, expect, it } from "vitest"; +import { getAgentHealthIssue } from "./health"; + +interface AgentOverrides { + status?: WorkspaceAgentStatus; + lifecycle_state?: WorkspaceAgentLifecycle; + parent_id?: string | null; +} + +/** + * Build a workspace mock with the given agent configurations and + * failing-agent count. Defaults to status "connected" and lifecycle + * "ready" so each test only needs to specify the fields it cares about. + */ +function buildWorkspace( + agents: AgentOverrides[], + failingAgentCount: number, +): Workspace { + return { + ...MockWorkspace, + latest_build: { + ...MockWorkspaceBuild, + resources: [ + { + ...MockWorkspaceResource, + agents: agents.map((overrides, i) => ({ + ...MockWorkspaceAgent, + id: `agent-${i}`, + name: `agent-${i}`, + status: overrides.status ?? "connected", + lifecycle_state: overrides.lifecycle_state ?? "ready", + parent_id: overrides.parent_id ?? null, + })), + }, + ], + }, + health: { + healthy: failingAgentCount === 0, + failing_agents: Array.from( + { length: failingAgentCount }, + (_, i) => `agent-${i}`, + ), + }, + }; +} + +describe("getAgentHealthIssue", () => { + describe("individual branches", () => { + it("returns disconnected issue for a disconnected agent", () => { + const ws = buildWorkspace( + [{ status: "disconnected", lifecycle_state: "ready" }], + 1, + ); + expect(getAgentHealthIssue(ws)).toEqual({ + title: "Workspace agent has disconnected", + detail: + "Check the log output for errors. If agents do not reconnect, try restarting the workspace.", + severity: "warning", + prominent: true, + }); + }); + + it("returns timeout issue for a timed-out agent", () => { + const ws = buildWorkspace( + [{ status: "timeout", lifecycle_state: "ready" }], + 1, + ); + expect(getAgentHealthIssue(ws)).toEqual({ + title: "Agent is taking longer than expected to connect", + detail: + "Continue to wait and check the log output for errors. If agents do not connect, try restarting the workspace.", + severity: "warning", + prominent: false, + }); + }); + + it("returns shutting down issue for shutting_down lifecycle", () => { + const ws = buildWorkspace( + [{ status: "connected", lifecycle_state: "shutting_down" }], + 1, + ); + expect(getAgentHealthIssue(ws)).toEqual({ + title: "Workspace agent is shutting down", + detail: "The workspace is not available while agents shut down.", + severity: "info", + prominent: false, + }); + }); + + it("returns shutting down issue for shutdown_error lifecycle", () => { + const ws = buildWorkspace( + [{ status: "connected", lifecycle_state: "shutdown_error" }], + 1, + ); + expect(getAgentHealthIssue(ws)).toEqual({ + title: "Workspace agent is shutting down", + detail: "The workspace is not available while agents shut down.", + severity: "info", + prominent: false, + }); + }); + + it("returns shutting down issue for shutdown_timeout lifecycle", () => { + const ws = buildWorkspace( + [{ status: "connected", lifecycle_state: "shutdown_timeout" }], + 1, + ); + expect(getAgentHealthIssue(ws)).toEqual({ + title: "Workspace agent is shutting down", + detail: "The workspace is not available while agents shut down.", + severity: "info", + prominent: false, + }); + }); + + it("returns start error issue for start_error lifecycle", () => { + const ws = buildWorkspace( + [{ status: "connected", lifecycle_state: "start_error" }], + 1, + ); + expect(getAgentHealthIssue(ws)).toEqual({ + title: "Startup script failed", + detail: + "A startup script exited with an error. Check the agent logs for details.", + severity: "warning", + prominent: true, + }); + }); + + it("returns start timeout issue for start_timeout lifecycle", () => { + const ws = buildWorkspace( + [{ status: "connected", lifecycle_state: "start_timeout" }], + 1, + ); + expect(getAgentHealthIssue(ws)).toEqual({ + title: "Startup script is taking longer than expected", + detail: + "A startup script has exceeded the expected time. Check the agent logs for details.", + severity: "warning", + prominent: false, + }); + }); + + it("returns connecting issue as default fallback", () => { + const ws = buildWorkspace( + [{ status: "connecting", lifecycle_state: "starting" }], + 1, + ); + expect(getAgentHealthIssue(ws)).toEqual({ + title: "Workspace agent is still connecting", + detail: "Check the log output if the connection does not complete.", + severity: "info", + prominent: false, + }); + }); + }); + + describe("plural path", () => { + it("uses plural title when multiple agents are disconnected", () => { + const ws = buildWorkspace( + [ + { status: "disconnected", lifecycle_state: "ready" }, + { status: "disconnected", lifecycle_state: "ready" }, + { status: "disconnected", lifecycle_state: "ready" }, + ], + 3, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("3 workspace agents have disconnected"); + }); + + it("uses plural title when multiple agents time out", () => { + const ws = buildWorkspace( + [ + { status: "timeout", lifecycle_state: "ready" }, + { status: "timeout", lifecycle_state: "ready" }, + ], + 2, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe( + "2 agents are taking longer than expected to connect", + ); + }); + + it("uses plural title when multiple agents have start errors", () => { + const ws = buildWorkspace( + [ + { status: "connected", lifecycle_state: "start_error" }, + { status: "connected", lifecycle_state: "start_error" }, + ], + 2, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("Startup scripts failed on 2 agents"); + }); + + it("uses plural title when multiple agents are shutting down", () => { + const ws = buildWorkspace( + [ + { status: "connected", lifecycle_state: "shutting_down" }, + { status: "connected", lifecycle_state: "shutdown_error" }, + ], + 2, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("2 workspace agents are shutting down"); + }); + + it("uses plural title when multiple agents are connecting", () => { + const ws = buildWorkspace( + [ + { status: "connecting", lifecycle_state: "starting" }, + { status: "connecting", lifecycle_state: "starting" }, + ], + 2, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("2 workspace agents are still connecting"); + }); + + it("uses singular title when only one agent is failing", () => { + const ws = buildWorkspace( + [{ status: "disconnected", lifecycle_state: "ready" }], + 1, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("Workspace agent has disconnected"); + }); + }); + + describe("priority ordering", () => { + it("disconnected takes priority over timeout", () => { + const ws = buildWorkspace( + [ + { status: "disconnected", lifecycle_state: "ready" }, + { status: "timeout", lifecycle_state: "ready" }, + ], + 2, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("2 workspace agents have disconnected"); + expect(result.severity).toBe("warning"); + expect(result.prominent).toBe(true); + }); + + it("timeout takes priority over shutdown states", () => { + const ws = buildWorkspace( + [ + { status: "timeout", lifecycle_state: "ready" }, + { status: "connected", lifecycle_state: "shutting_down" }, + ], + 2, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe( + "2 agents are taking longer than expected to connect", + ); + expect(result.severity).toBe("warning"); + expect(result.prominent).toBe(false); + }); + + it("shutdown states take priority over start_error", () => { + const ws = buildWorkspace( + [ + { status: "connected", lifecycle_state: "shutting_down" }, + { status: "connected", lifecycle_state: "start_error" }, + ], + 2, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("2 workspace agents are shutting down"); + expect(result.severity).toBe("info"); + }); + + it("start_error takes priority over start_timeout", () => { + const ws = buildWorkspace( + [ + { status: "connected", lifecycle_state: "start_error" }, + { status: "connected", lifecycle_state: "start_timeout" }, + ], + 2, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("Startup scripts failed on 2 agents"); + expect(result.severity).toBe("warning"); + expect(result.prominent).toBe(true); + }); + + it("disconnected takes priority over all lifecycle states", () => { + const ws = buildWorkspace( + [ + { status: "disconnected", lifecycle_state: "start_error" }, + { status: "connected", lifecycle_state: "shutting_down" }, + { status: "connected", lifecycle_state: "start_timeout" }, + ], + 3, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("3 workspace agents have disconnected"); + }); + }); + + describe("sub-agent filtering", () => { + it("ignores a sub-agent whose status would change the result", () => { + const ws = buildWorkspace( + [ + // Parent agent: still connecting. + { status: "connecting", lifecycle_state: "starting" }, + // Sub-agent: disconnected, which would be highest priority + // if not filtered out. + { + status: "disconnected", + lifecycle_state: "ready", + parent_id: "agent-0", + }, + ], + 1, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("Workspace agent is still connecting"); + expect(result.severity).toBe("info"); + }); + + it("ignores a sub-agent whose lifecycle would promote severity", () => { + const ws = buildWorkspace( + [ + // Parent agent: soft start_timeout issue. + { status: "connected", lifecycle_state: "start_timeout" }, + // Sub-agent: start_error, which would take priority over + // start_timeout if not filtered. + { + status: "connected", + lifecycle_state: "start_error", + parent_id: "agent-0", + }, + ], + 1, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe( + "Startup script is taking longer than expected", + ); + expect(result.prominent).toBe(false); + }); + }); + + describe("start_timeout reachability", () => { + it("is overshadowed by start_error in a multi-agent workspace", () => { + const ws = buildWorkspace( + [ + { status: "connected", lifecycle_state: "start_timeout" }, + { status: "connected", lifecycle_state: "start_error" }, + ], + 2, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe("Startup scripts failed on 2 agents"); + expect(result.prominent).toBe(true); + }); + + it("is returned when it is the sole lifecycle issue", () => { + // In a multi-agent workspace another agent may have triggered + // the unhealthy flag while this agent only has start_timeout. + const ws = buildWorkspace( + [ + { status: "connected", lifecycle_state: "start_timeout" }, + { status: "connected", lifecycle_state: "ready" }, + ], + 1, + ); + const result = getAgentHealthIssue(ws); + expect(result.title).toBe( + "Startup script is taking longer than expected", + ); + expect(result.severity).toBe("warning"); + expect(result.prominent).toBe(false); + }); + }); +}); diff --git a/site/src/modules/workspaces/health.ts b/site/src/modules/workspaces/health.ts new file mode 100644 index 0000000000..3b4dd43365 --- /dev/null +++ b/site/src/modules/workspaces/health.ts @@ -0,0 +1,124 @@ +import type { Workspace, WorkspaceAgentStatus } from "api/typesGenerated"; + +interface AgentHealthIssue { + title: string; + detail: string; + severity: "info" | "warning"; + // Whether the alert should be visually prominent. Usually true for + // warnings, but connection timeout and startup timeout are + // exceptions (warning severity without prominent styling). + prominent: boolean; +} + +/** + * Classifies the health issue affecting a workspace based on agent + * status and lifecycle state. Returns a title and detail message + * that accurately describes the root cause rather than using a + * generic "unhealthy" label. + */ +export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue { + const failingAgentCount = workspace.health.failing_agents.length; + const statusSet = new Set(); + let hasStartError = false; + let hasStartTimeout = false; + let hasShutdownState = false; + + for (const resource of workspace.latest_build.resources) { + for (const agent of resource.agents ?? []) { + // Skip sub-agents (devcontainer agents) to match the + // backend health calculation which excludes them. + if (agent.parent_id !== null) { + continue; + } + statusSet.add(agent.status); + if (agent.lifecycle_state === "start_error") { + hasStartError = true; + } + if (agent.lifecycle_state === "start_timeout") { + hasStartTimeout = true; + } + if ( + agent.lifecycle_state === "shutting_down" || + agent.lifecycle_state === "shutdown_error" || + agent.lifecycle_state === "shutdown_timeout" + ) { + hasShutdownState = true; + } + } + } + + const plural = failingAgentCount > 1; + + if (statusSet.has("disconnected")) { + return { + title: plural + ? `${failingAgentCount} workspace agents have disconnected` + : "Workspace agent has disconnected", + detail: + "Check the log output for errors. If agents do not reconnect, try restarting the workspace.", + severity: "warning", + prominent: true, + }; + } + + if (statusSet.has("timeout")) { + return { + title: plural + ? `${failingAgentCount} agents are taking longer than expected to connect` + : "Agent is taking longer than expected to connect", + detail: + "Continue to wait and check the log output for errors. If agents do not connect, try restarting the workspace.", + severity: "warning", + prominent: false, + }; + } + + if (hasShutdownState) { + return { + title: plural + ? `${failingAgentCount} workspace agents are shutting down` + : "Workspace agent is shutting down", + detail: "The workspace is not available while agents shut down.", + severity: "info", + prominent: false, + }; + } + + if (hasStartError) { + return { + title: plural + ? `Startup scripts failed on ${failingAgentCount} agents` + : "Startup script failed", + detail: + "A startup script exited with an error. Check the agent logs for details.", + severity: "warning", + prominent: true, + }; + } + + // The backend does not mark start_timeout agents as unhealthy on + // their own (it treats it as a soft issue). This branch is only + // reachable in multi-agent workspaces where a different agent + // triggered the unhealthy flag but none of the higher-priority + // branches matched. + if (hasStartTimeout) { + return { + title: plural + ? `Startup scripts are taking longer than expected on ${failingAgentCount} agents` + : "Startup script is taking longer than expected", + detail: + "A startup script has exceeded the expected time. Check the agent logs for details.", + severity: "warning", + prominent: false, + }; + } + + return { + title: plural + ? `${failingAgentCount} workspace agents are still connecting` + : "Workspace agent is still connecting", + detail: "Check the log output if the connection does not complete.", + severity: "info", + prominent: false, + }; +} diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 96c0fbbacf..183c7194a8 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -3,6 +3,7 @@ import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { BlocksIcon, HistoryIcon } from "lucide-react"; import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { AgentRow } from "modules/resources/AgentRow"; +import { getAgentHealthIssue } from "modules/workspaces/health"; import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings"; import type { FC } from "react"; import { useNavigate } from "react-router"; @@ -193,7 +194,7 @@ export const Workspace: FC = ({ {!workspace.health.healthy && ( )} diff --git a/site/src/pages/WorkspacePage/WorkspaceAlert.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceAlert.stories.tsx index c3c78ae04c..4ca68e98e6 100644 --- a/site/src/pages/WorkspacePage/WorkspaceAlert.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceAlert.stories.tsx @@ -1,106 +1,52 @@ -import { - MockWorkspace, - MockWorkspaceAgent, - MockWorkspaceResource, -} from "testHelpers/entities"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; import { WorkspaceAlert } from "./WorkspaceAlert"; -const createUnhealthyWorkspace = ( - agentOverrides: Partial, - agentCount = 1, -): Workspace => { - const agents = Array.from({ length: agentCount }, (_, i) => ({ - ...MockWorkspaceAgent, - id: `test-agent-${i}`, - name: `agent-${i}`, - health: { healthy: false }, - ...agentOverrides, - })); - return { - ...MockWorkspace, - health: { - healthy: false, - failing_agents: agents.map((a) => a.id), - }, - latest_build: { - ...MockWorkspace.latest_build, - resources: [{ ...MockWorkspaceResource, agents }], - }, - }; -}; - const meta: Meta = { title: "pages/WorkspacePage/WorkspaceAlert", component: WorkspaceAlert, + args: { + title: "Something went wrong", + detail: + "A useful description of what happened and what the user can do about it.", + troubleshootingURL: "https://coder.com/docs/troubleshoot", + }, }; export default meta; type Story = StoryObj; -export const Disconnected: Story = { +export const WarningProminent: Story = { args: { - workspace: createUnhealthyWorkspace({ status: "disconnected" }), - troubleshootingURL: "https://coder.com/docs/troubleshoot", + severity: "warning", + prominent: true, }, }; -export const DisconnectedMultipleAgents: Story = { +export const WarningSubtle: Story = { args: { - workspace: createUnhealthyWorkspace({ status: "disconnected" }, 3), - troubleshootingURL: "https://coder.com/docs/troubleshoot", + severity: "warning", + prominent: false, }, }; -export const TimeoutWarning: Story = { +export const InfoProminent: Story = { args: { - workspace: createUnhealthyWorkspace({ status: "timeout" }), - troubleshootingURL: "https://coder.com/docs/troubleshoot", + severity: "info", + prominent: true, }, }; -export const StartupScriptFailed: Story = { +export const InfoSubtle: Story = { args: { - workspace: createUnhealthyWorkspace({ - status: "connected", - lifecycle_state: "start_error", - }), - troubleshootingURL: "https://coder.com/docs/troubleshoot", - }, -}; - -export const StartupScriptFailedMultipleAgents: Story = { - args: { - workspace: createUnhealthyWorkspace( - { - status: "connected", - lifecycle_state: "start_error", - }, - 2, - ), - troubleshootingURL: "https://coder.com/docs/troubleshoot", - }, -}; - -export const ShuttingDownInformational: Story = { - args: { - workspace: createUnhealthyWorkspace({ - status: "connected", - lifecycle_state: "shutting_down", - }), - }, -}; - -export const NotConnected: Story = { - args: { - workspace: createUnhealthyWorkspace({ status: "connecting" }), + severity: "info", + prominent: false, }, }; export const WithoutTroubleshootingURL: Story = { args: { - workspace: createUnhealthyWorkspace({ status: "disconnected" }), + severity: "warning", + prominent: true, troubleshootingURL: undefined, }, }; diff --git a/site/src/pages/WorkspacePage/WorkspaceAlert.tsx b/site/src/pages/WorkspacePage/WorkspaceAlert.tsx index 3ebe043bf9..0343309856 100644 --- a/site/src/pages/WorkspacePage/WorkspaceAlert.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceAlert.tsx @@ -1,88 +1,27 @@ -import type * as TypesGen from "api/typesGenerated"; -import type { WorkspaceAgentStatus } from "api/typesGenerated"; import type { FC } from "react"; import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { Link } from "#/components/Link/Link"; interface WorkspaceAlertProps { - workspace: TypesGen.Workspace; + title: string; + detail: string; + severity: "info" | "warning"; + prominent: boolean; troubleshootingURL: string | undefined; } export const WorkspaceAlert: FC = ({ - workspace, + title, + detail, + severity, + prominent, troubleshootingURL, }) => { - const failingAgentCount = workspace.health.failing_agents.length; - const statusSet = new Set(); - let hasStartError = false; - let hasShuttingDown = false; - - for (const resource of workspace.latest_build.resources) { - for (const agent of resource.agents ?? []) { - statusSet.add(agent.status); - if (agent.lifecycle_state === "start_error") { - hasStartError = true; - } - if ( - agent.lifecycle_state === "shutting_down" || - agent.lifecycle_state === "shutdown_error" || - agent.lifecycle_state === "shutdown_timeout" - ) { - hasShuttingDown = true; - } - } - } - - const plural = failingAgentCount > 1; - - let title: string; - let message: string; - let severity: "info" | "warning" = "warning"; - let prominent = true; - - if (statusSet.has("disconnected")) { - title = plural - ? `${failingAgentCount} workspace agents have disconnected` - : "Workspace agent has disconnected"; - message = - "Check the log output for errors. If the agent does not reconnect, try restarting the workspace."; - } else if (statusSet.has("timeout")) { - title = plural - ? `${failingAgentCount} agents are taking longer than expected to connect` - : "Agent is taking longer than expected to connect"; - message = - "Continue to wait and check the log output for errors. If the agent does not connect, try restarting the workspace."; - severity = "warning"; - prominent = false; - } else if (hasShuttingDown) { - title = plural - ? `${failingAgentCount} workspace agents are shutting down` - : "Workspace agent is shutting down"; - message = "The workspace is not available while the agent shuts down."; - severity = "info"; - prominent = false; - } else if (hasStartError) { - title = plural - ? `Startup scripts failed on ${failingAgentCount} agents` - : "Startup script failed"; - message = - "The workspace is running but a startup script exited with an error. Check the agent logs for details."; - } else { - title = plural - ? `${failingAgentCount} workspace agents are still connecting` - : "Workspace agent is still connecting"; - message = - "The workspace agent is still connecting. Check the log output if the connection does not complete."; - severity = "info"; - prominent = false; - } - return ( {title} -

{message}

+

{detail}

{troubleshootingURL && ( diff --git a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx index 3aad898aa6..1af4692236 100644 --- a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx @@ -4,10 +4,13 @@ import { MockTemplateVersion, MockTemplateVersionWithMarkdownMessage, MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceResource, } from "testHelpers/entities"; import { withDashboardProvider } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { getWorkspaceResolveAutostartQueryKey } from "api/queries/workspaceQuota"; +import type { Workspace } from "api/typesGenerated"; import type { WorkspacePermissions } from "modules/workspaces/permissions"; import { expect, screen, userEvent, waitFor } from "storybook/test"; import { WorkspaceNotifications } from "./WorkspaceNotifications"; @@ -112,19 +115,40 @@ export const RequiresManualUpdate: Story = { }, }; -export const Unhealthy: Story = { - args: { - workspace: { - ...MockWorkspace, - health: { - ...MockWorkspace.health, - healthy: false, - }, - latest_build: { - ...MockWorkspace.latest_build, - status: "running", - }, +/** + * Creates a workspace with unhealthy agents using the given agent + * overrides, for use in notification stories. + */ +function createUnhealthyWorkspace( + agentOverrides: Partial, +): Workspace { + const agent = { ...MockWorkspaceAgent, ...agentOverrides }; + return { + ...MockWorkspace, + health: { + healthy: false, + failing_agents: [agent.id], }, + latest_build: { + ...MockWorkspace.latest_build, + status: "running", + resources: [ + { + ...MockWorkspaceResource, + agents: [agent], + }, + ], + }, + }; +} + +export const StartupScriptFailed: Story = { + args: { + workspace: createUnhealthyWorkspace({ + status: "connected", + lifecycle_state: "start_error", + health: { healthy: false }, + }), }, play: async ({ step }) => { @@ -132,23 +156,65 @@ export const Unhealthy: Story = { await userEvent.hover(screen.getByTestId("warning-notifications")); await waitFor(() => expect(screen.getByRole("tooltip")).toHaveTextContent( - /workspace is unhealthy/i, + /startup script failed/i, ), ); }); }, }; -export const UnhealthyWithoutUpdatePermission: Story = { +export const AgentDisconnected: Story = { args: { - ...Unhealthy.args, + workspace: createUnhealthyWorkspace({ + status: "disconnected", + lifecycle_state: "ready", + health: { healthy: false }, + }), + }, + + play: async ({ step }) => { + await step("activate hover trigger", async () => { + await userEvent.hover(screen.getByTestId("warning-notifications")); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent( + /agent has disconnected/i, + ), + ); + }); + }, +}; + +export const AgentTimeout: Story = { + args: { + workspace: createUnhealthyWorkspace({ + status: "timeout", + lifecycle_state: "starting", + health: { healthy: false }, + }), + }, + + play: async ({ step }) => { + await step("activate hover trigger", async () => { + await userEvent.hover(screen.getByTestId("warning-notifications")); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent( + /taking longer than expected/i, + ), + ); + }); + }, +}; + +export const StartupScriptFailedWithoutUpdatePermission: Story = { + args: { + ...StartupScriptFailed.args, permissions: { ...defaultPermissions, updateWorkspace: false, }, }, - play: Unhealthy.play, + play: StartupScriptFailed.play, }; const DormantWorkspace = { diff --git a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx index 3c44883b63..278244e575 100644 --- a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx @@ -11,6 +11,7 @@ import relativeTime from "dayjs/plugin/relativeTime"; import { InfoIcon, TriangleAlertIcon } from "lucide-react"; import { useDashboard } from "modules/dashboard/useDashboard"; import { TemplateUpdateMessage } from "modules/templates/TemplateUpdateMessage"; +import { getAgentHealthIssue } from "modules/workspaces/health"; import { type FC, useEffect, useState } from "react"; import { MemoizedInlineMarkdown } from "#/components/Markdown/Markdown"; @@ -92,19 +93,12 @@ export const WorkspaceNotifications: FC = ({ ) { const troubleshootingURL = findTroubleshootingURL(workspace.latest_build); const hasActions = permissions.updateWorkspace || troubleshootingURL; + const healthIssue = getAgentHealthIssue(workspace); notifications.push({ - title: "Workspace is unhealthy", - severity: "warning", - detail: ( - <> - Your workspace is running but{" "} - {workspace.health.failing_agents.length > 1 - ? `${workspace.health.failing_agents.length} agents are unhealthy` - : "1 agent is unhealthy"} - . - - ), + title: healthIssue.title, + severity: healthIssue.severity, + detail: healthIssue.detail, actions: hasActions ? ( <> {permissions.updateWorkspace && (