mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): show accurate health messages in workspace hover menu and status tooltip (#23591)
This commit is contained in:
+23
-2
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<WorkspaceStatusIndicatorProps> = ({
|
||||
{children}
|
||||
</StatusIndicator>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Your workspace is running but some agents are unhealthy.
|
||||
</TooltipContent>
|
||||
<TooltipContent>{getAgentHealthIssue(workspace).detail}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<WorkspaceAgentStatus>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<WorkspaceProps> = ({
|
||||
|
||||
{!workspace.health.healthy && (
|
||||
<WorkspaceAlert
|
||||
workspace={workspace}
|
||||
{...getAgentHealthIssue(workspace)}
|
||||
troubleshootingURL={troubleshootingURL}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<WorkspaceAgent>,
|
||||
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<typeof WorkspaceAlert> = {
|
||||
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<typeof WorkspaceAlert>;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<WorkspaceAlertProps> = ({
|
||||
workspace,
|
||||
title,
|
||||
detail,
|
||||
severity,
|
||||
prominent,
|
||||
troubleshootingURL,
|
||||
}) => {
|
||||
const failingAgentCount = workspace.health.failing_agents.length;
|
||||
const statusSet = new Set<WorkspaceAgentStatus>();
|
||||
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 (
|
||||
<Alert severity={severity} prominent={prominent}>
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{message}</p>
|
||||
<p>{detail}</p>
|
||||
<p>
|
||||
{troubleshootingURL && (
|
||||
<Link href={troubleshootingURL} target="_blank">
|
||||
|
||||
+82
-16
@@ -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<typeof MockWorkspaceAgent>,
|
||||
): 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 = {
|
||||
|
||||
@@ -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<WorkspaceNotificationsProps> = ({
|
||||
) {
|
||||
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 && (
|
||||
|
||||
Reference in New Issue
Block a user