feat: refactor <AgentLogs /> error state (#24233)
This pull-request addresses a few design things within the `<AgentRow />` element. This is a follow-on from the previous work done with implementing tabs. - Workspace border can no longer be red, will always be orange (this was done in a previous PR but not stated). - Warnings have been moved to inside the Agent Logs collapsible. - Warning badge has been added to the Agent Logs collapsible trigger. - Collapsible is now open by default when there is an error inside of the agent. - Agent disconnected is no longer prominent by default.
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
EllipsisIcon,
|
||||
PlayIcon,
|
||||
SquareCheckBigIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
} from "#/api/typesGenerated";
|
||||
import { CheckIcon } from "#/components/AnimatedIcons/Check";
|
||||
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -45,6 +47,8 @@ import { useKebabMenu } from "#/components/Tabs/utils/useKebabMenu";
|
||||
import { useProxy } from "#/contexts/ProxyContext";
|
||||
import { useClipboard } from "#/hooks/useClipboard";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { getAgentHealthIssues } from "#/modules/workspaces/health";
|
||||
import { AgentAlert } from "#/pages/WorkspacePage/AgentAlert";
|
||||
import { AppStatuses } from "#/pages/WorkspacePage/AppStatuses";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
|
||||
@@ -135,9 +139,12 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
const showVSCode = hasVSCodeApp && !browser_only;
|
||||
|
||||
const hasStartupFeatures = Boolean(agent.logs_length);
|
||||
const healthIssues = getAgentHealthIssues(agent);
|
||||
const hasAgentIssues = healthIssues.length > 0;
|
||||
const { proxy } = useProxy();
|
||||
const [showLogs, setShowLogs] = useState(
|
||||
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
|
||||
(["starting", "start_timeout"].includes(agent.lifecycle_state) ||
|
||||
hasAgentIssues) &&
|
||||
hasStartupFeatures,
|
||||
);
|
||||
const agentLogs = useAgentLogs({ agentId: agent.id, enabled: showLogs });
|
||||
@@ -146,8 +153,11 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
const [bottomOfLogs, setBottomOfLogs] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setShowLogs(agent.lifecycle_state !== "ready" && hasStartupFeatures);
|
||||
}, [agent.lifecycle_state, hasStartupFeatures]);
|
||||
setShowLogs(
|
||||
(agent.lifecycle_state !== "ready" || hasAgentIssues) &&
|
||||
hasStartupFeatures,
|
||||
);
|
||||
}, [agent.lifecycle_state, hasAgentIssues, hasStartupFeatures]);
|
||||
|
||||
// This is a layout effect to remove flicker when we're scrolling to the bottom.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: consider refactoring
|
||||
@@ -208,7 +218,6 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
agent,
|
||||
Boolean(hasDevcontainerErrors || shouldShowWildcardWarning),
|
||||
);
|
||||
|
||||
const [selectedLogTab, setSelectedLogTab] = useState("all");
|
||||
const sourceLogTabs = agent.log_sources
|
||||
.filter((logSource) => {
|
||||
@@ -459,20 +468,37 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
<AgentMetadata initialMetadata={initialMetadata} agent={agent} />
|
||||
</div>
|
||||
|
||||
{hasStartupFeatures && (
|
||||
<section className="border-0 border-t border-solid border-border">
|
||||
<div className="px-4 py-2 relative">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
className="after:content-[''] after:absolute after:inset-0"
|
||||
>
|
||||
<ChevronDownIcon open={showLogs} />
|
||||
<span>Logs</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Collapse in={showLogs}>
|
||||
<div className="px-4 pb-4">
|
||||
<section className="border-0 border-t border-solid border-border">
|
||||
<div className="px-4 py-2 relative">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
className="after:content-[''] after:absolute after:inset-0"
|
||||
>
|
||||
<ChevronDownIcon open={showLogs} />
|
||||
<span>Logs</span>
|
||||
{healthIssues.length > 0 && (
|
||||
<Badge variant="warning" size="xs" className="ml-1.5">
|
||||
<TriangleAlertIcon />
|
||||
<span>{healthIssues.length}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Collapse in={showLogs || (!hasStartupFeatures && hasAgentIssues)}>
|
||||
<div className={cn("px-4", hasStartupFeatures ? "pb-4" : "py-4")}>
|
||||
{healthIssues.length > 0 && (
|
||||
<div className="mb-4 flex flex-col gap-3">
|
||||
{healthIssues.map((issue) => (
|
||||
<AgentAlert
|
||||
key={`${issue.title}-${issue.detail}`}
|
||||
{...issue}
|
||||
troubleshootingURL={agent.troubleshooting_url}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasStartupFeatures && hasAnyLogs && (
|
||||
<div className="border border-solid rounded-md overflow-clip">
|
||||
<Tabs
|
||||
className="-mx-px -mt-px"
|
||||
@@ -585,10 +611,10 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse>
|
||||
</section>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</Collapse>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { getAgentHealthIssue } from "#/modules/workspaces/health";
|
||||
import {
|
||||
type DisplayWorkspaceStatusType,
|
||||
getDisplayWorkspaceStatus,
|
||||
@@ -68,7 +67,10 @@ export const WorkspaceStatusIndicator: FC<WorkspaceStatusIndicatorProps> = ({
|
||||
{children}
|
||||
</StatusIndicator>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getAgentHealthIssue(workspace).detail}</TooltipContent>
|
||||
<TooltipContent>
|
||||
One or more workspace agents need attention. Expand an agent's logs for
|
||||
details.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceAgentLifecycle,
|
||||
WorkspaceAgentStatus,
|
||||
} from "#/api/typesGenerated";
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceBuild,
|
||||
MockWorkspaceResource,
|
||||
} from "#/testHelpers/entities";
|
||||
import { getAgentHealthIssue } from "./health";
|
||||
import { MockWorkspaceAgent } from "#/testHelpers/entities";
|
||||
import { getAgentHealthIssues } from "./health";
|
||||
|
||||
interface AgentOverrides {
|
||||
status?: WorkspaceAgentStatus;
|
||||
@@ -18,375 +13,119 @@ interface AgentOverrides {
|
||||
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 {
|
||||
function buildAgent(overrides: AgentOverrides): WorkspaceAgent {
|
||||
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}`,
|
||||
),
|
||||
},
|
||||
...MockWorkspaceAgent,
|
||||
status: overrides.status ?? "connected",
|
||||
lifecycle_state: overrides.lifecycle_state ?? "ready",
|
||||
parent_id: overrides.parent_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
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({
|
||||
describe("getAgentHealthIssues", () => {
|
||||
it("returns disconnected issue for a disconnected agent", () => {
|
||||
expect(
|
||||
getAgentHealthIssues(buildAgent({ status: "disconnected" })),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
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,
|
||||
});
|
||||
});
|
||||
prominent: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns timeout issue for a timed-out agent", () => {
|
||||
const ws = buildWorkspace(
|
||||
[{ status: "timeout", lifecycle_state: "ready" }],
|
||||
1,
|
||||
);
|
||||
expect(getAgentHealthIssue(ws)).toEqual({
|
||||
it("returns timeout issue for a timed-out agent", () => {
|
||||
expect(
|
||||
getAgentHealthIssues(buildAgent({ status: "timeout" })),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
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({
|
||||
it("returns shutdown issue for shutdown lifecycle states", () => {
|
||||
expect(
|
||||
getAgentHealthIssues(buildAgent({ lifecycle_state: "shutting_down" })),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
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({
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
getAgentHealthIssues(buildAgent({ lifecycle_state: "shutdown_error" })),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
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({
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
getAgentHealthIssues(buildAgent({ lifecycle_state: "shutdown_timeout" })),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
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({
|
||||
it("returns startup script issues", () => {
|
||||
expect(
|
||||
getAgentHealthIssues(buildAgent({ lifecycle_state: "start_error" })),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
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,
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
getAgentHealthIssues(buildAgent({ lifecycle_state: "start_timeout" })),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
title: "Startup script is taking longer than expected",
|
||||
severity: "warning",
|
||||
prominent: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns connecting issue for a connecting agent", () => {
|
||||
const ws = buildWorkspace(
|
||||
[{ status: "connecting", lifecycle_state: "starting" }],
|
||||
1,
|
||||
);
|
||||
expect(getAgentHealthIssue(ws)).toEqual({
|
||||
it("returns connecting issue for a connecting agent", () => {
|
||||
expect(
|
||||
getAgentHealthIssues(
|
||||
buildAgent({ status: "connecting", lifecycle_state: "starting" }),
|
||||
),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
title: "Workspace agent is connecting",
|
||||
detail:
|
||||
"The workspace agent has not connected yet. Wait for it to connect or check the logs if it does not.",
|
||||
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 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");
|
||||
});
|
||||
it("returns empty list for healthy ready connected agent", () => {
|
||||
expect(
|
||||
getAgentHealthIssues(
|
||||
buildAgent({ status: "connected", lifecycle_state: "ready" }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
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 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);
|
||||
});
|
||||
it("returns multiple issues when multiple conditions match", () => {
|
||||
const issues = getAgentHealthIssues(
|
||||
buildAgent({ status: "disconnected", lifecycle_state: "start_error" }),
|
||||
);
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({ title: "Workspace agent has disconnected" }),
|
||||
);
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({ title: "Startup script failed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Workspace, WorkspaceAgentStatus } from "#/api/typesGenerated";
|
||||
import type { WorkspaceAgent } from "#/api/typesGenerated";
|
||||
|
||||
/**
|
||||
* Canonical messages for startup and shutdown script issues.
|
||||
* Used by the per-agent-row tooltips in AgentStatus; the
|
||||
* start-related entries are also shared with the workspace-level
|
||||
* health classification in getAgentHealthIssue.
|
||||
* start-related entries are also shared with per-agent health
|
||||
* classification in getAgentHealthIssues.
|
||||
*/
|
||||
export const agentScriptMessages = {
|
||||
start_error: {
|
||||
@@ -62,121 +62,70 @@ interface AgentHealthIssue {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Classifies all health issues for an individual agent.
|
||||
*/
|
||||
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;
|
||||
export function getAgentHealthIssues(
|
||||
agent: WorkspaceAgent,
|
||||
): AgentHealthIssue[] {
|
||||
const issues: AgentHealthIssue[] = [];
|
||||
|
||||
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`
|
||||
: agentConnectionMessages.disconnected.title,
|
||||
if (agent.status === "disconnected") {
|
||||
issues.push({
|
||||
title: agentConnectionMessages.disconnected.title,
|
||||
detail: agentConnectionMessages.disconnected.detail,
|
||||
severity: "warning",
|
||||
prominent: true,
|
||||
};
|
||||
prominent: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (statusSet.has("timeout")) {
|
||||
return {
|
||||
title: plural
|
||||
? `${failingAgentCount} agents are taking longer than expected to connect`
|
||||
: agentConnectionMessages.timeout.title,
|
||||
if (agent.status === "timeout") {
|
||||
issues.push({
|
||||
title: agentConnectionMessages.timeout.title,
|
||||
detail: agentConnectionMessages.timeout.detail,
|
||||
severity: "warning",
|
||||
prominent: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (hasShutdownState) {
|
||||
return {
|
||||
title: plural
|
||||
? `${failingAgentCount} workspace agents are shutting down`
|
||||
: "Workspace agent is shutting down",
|
||||
if (
|
||||
agent.lifecycle_state === "shutting_down" ||
|
||||
agent.lifecycle_state === "shutdown_error" ||
|
||||
agent.lifecycle_state === "shutdown_timeout"
|
||||
) {
|
||||
issues.push({
|
||||
title: "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`
|
||||
: agentScriptMessages.start_error.title,
|
||||
if (agent.lifecycle_state === "start_error") {
|
||||
issues.push({
|
||||
title: agentScriptMessages.start_error.title,
|
||||
detail: agentScriptMessages.start_error.detail,
|
||||
severity: "warning",
|
||||
prominent: true,
|
||||
};
|
||||
prominent: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 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`
|
||||
: agentScriptMessages.start_timeout.title,
|
||||
if (agent.lifecycle_state === "start_timeout") {
|
||||
issues.push({
|
||||
title: agentScriptMessages.start_timeout.title,
|
||||
detail: agentScriptMessages.start_timeout.detail,
|
||||
severity: "warning",
|
||||
prominent: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (statusSet.has("connecting")) {
|
||||
return {
|
||||
title: plural
|
||||
? `${failingAgentCount} workspace agents are connecting`
|
||||
: agentConnectionMessages.connecting.title,
|
||||
if (agent.status === "connecting") {
|
||||
issues.push({
|
||||
title: agentConnectionMessages.connecting.title,
|
||||
detail: agentConnectionMessages.connecting.detail,
|
||||
severity: "info",
|
||||
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,
|
||||
};
|
||||
return issues;
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { WorkspaceAlert } from "./WorkspaceAlert";
|
||||
import { AgentAlert } from "./AgentAlert";
|
||||
|
||||
const meta: Meta<typeof WorkspaceAlert> = {
|
||||
title: "pages/WorkspacePage/WorkspaceAlert",
|
||||
component: WorkspaceAlert,
|
||||
const meta: Meta<typeof AgentAlert> = {
|
||||
title: "pages/WorkspacePage/AgentAlert",
|
||||
component: AgentAlert,
|
||||
args: {
|
||||
title: "Something went wrong",
|
||||
detail:
|
||||
@@ -13,7 +13,7 @@ const meta: Meta<typeof WorkspaceAlert> = {
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkspaceAlert>;
|
||||
type Story = StoryObj<typeof AgentAlert>;
|
||||
|
||||
export const WarningProminent: Story = {
|
||||
args: {
|
||||
+11
-11
@@ -1,16 +1,16 @@
|
||||
import type { FC } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
|
||||
interface WorkspaceAlertProps {
|
||||
interface AgentAlertProps {
|
||||
title: string;
|
||||
detail: string;
|
||||
severity: "info" | "warning";
|
||||
prominent: boolean;
|
||||
troubleshootingURL: string | undefined;
|
||||
troubleshootingURL?: string;
|
||||
}
|
||||
|
||||
export const WorkspaceAlert: FC<WorkspaceAlertProps> = ({
|
||||
export const AgentAlert: FC<AgentAlertProps> = ({
|
||||
title,
|
||||
detail,
|
||||
severity,
|
||||
@@ -21,14 +21,14 @@ export const WorkspaceAlert: FC<WorkspaceAlertProps> = ({
|
||||
<Alert severity={severity} prominent={prominent}>
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{detail}</p>
|
||||
<p>
|
||||
{troubleshootingURL && (
|
||||
<Link href={troubleshootingURL} target="_blank">
|
||||
<div className="mb-2">{detail}</div>
|
||||
{troubleshootingURL && (
|
||||
<Button size="sm" asChild>
|
||||
<a href={troubleshootingURL} target="_blank" rel="noopener">
|
||||
View docs to troubleshoot
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
@@ -7,21 +7,18 @@ import { SidebarIconButton } from "#/components/FullPageLayout/Sidebar";
|
||||
import { useSearchParamsKey } from "#/hooks/useSearchParamsKey";
|
||||
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 { WorkspacePermissions } from "../../modules/workspaces/permissions";
|
||||
import { HistorySidebar } from "./HistorySidebar";
|
||||
import { ResourceMetadata } from "./ResourceMetadata";
|
||||
import { ResourcesSidebar } from "./ResourcesSidebar";
|
||||
import { resourceOptionValue, useResourcesNav } from "./useResourcesNav";
|
||||
import { WorkspaceAlert } from "./WorkspaceAlert";
|
||||
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
|
||||
import {
|
||||
getActiveTransitionStats,
|
||||
WorkspaceBuildProgress,
|
||||
} from "./WorkspaceBuildProgress";
|
||||
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
|
||||
import { findTroubleshootingURL } from "./WorkspaceNotifications/WorkspaceNotifications";
|
||||
import { WorkspaceTopbar } from "./WorkspaceTopbar";
|
||||
|
||||
interface WorkspaceProps {
|
||||
@@ -99,7 +96,6 @@ export const Workspace: FC<WorkspaceProps> = ({
|
||||
(workspace.latest_build.matched_provisioners?.available ?? 1) > 0;
|
||||
const shouldShowProvisionerAlert =
|
||||
workspacePending && !haveBuildLogs && !provisionersHealthy && !isRestarting;
|
||||
const troubleshootingURL = findTroubleshootingURL(workspace.latest_build);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
@@ -195,13 +191,6 @@ export const Workspace: FC<WorkspaceProps> = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!workspace.health.healthy && (
|
||||
<WorkspaceAlert
|
||||
{...getAgentHealthIssue(workspace)}
|
||||
troubleshootingURL={troubleshootingURL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{transitionStats !== undefined && (
|
||||
<WorkspaceBuildProgress
|
||||
workspace={workspace}
|
||||
|
||||
+3
-3
@@ -156,7 +156,7 @@ export const StartupScriptFailed: Story = {
|
||||
await userEvent.hover(screen.getByTestId("warning-notifications"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
/startup script failed/i,
|
||||
/one or more workspace agents need attention/i,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -177,7 +177,7 @@ export const AgentDisconnected: Story = {
|
||||
await userEvent.hover(screen.getByTestId("warning-notifications"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
/agent has disconnected/i,
|
||||
/one or more workspace agents need attention/i,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -198,7 +198,7 @@ export const AgentTimeout: Story = {
|
||||
await userEvent.hover(screen.getByTestId("warning-notifications"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
/taking longer than expected/i,
|
||||
/one or more workspace agents need attention/i,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
import { MemoizedInlineMarkdown } from "#/components/Markdown/InlineMarkdown";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { TemplateUpdateMessage } from "#/modules/templates/TemplateUpdateMessage";
|
||||
import { getAgentHealthIssue } from "#/modules/workspaces/health";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -93,12 +92,10 @@ export const WorkspaceNotifications: FC<WorkspaceNotificationsProps> = ({
|
||||
) {
|
||||
const troubleshootingURL = findTroubleshootingURL(workspace.latest_build);
|
||||
const hasActions = permissions.updateWorkspace || troubleshootingURL;
|
||||
const healthIssue = getAgentHealthIssue(workspace);
|
||||
|
||||
notifications.push({
|
||||
title: healthIssue.title,
|
||||
severity: healthIssue.severity,
|
||||
detail: healthIssue.detail,
|
||||
title: "One or more workspace agents need attention",
|
||||
severity: "warning",
|
||||
detail: "Expand an agent's logs to view per-agent health details.",
|
||||
actions: hasActions ? (
|
||||
<>
|
||||
{permissions.updateWorkspace && (
|
||||
@@ -270,7 +267,7 @@ const styles = {
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export const findTroubleshootingURL = (
|
||||
const findTroubleshootingURL = (
|
||||
workspaceBuild: WorkspaceBuild,
|
||||
): string | undefined => {
|
||||
for (const resource of workspaceBuild.resources) {
|
||||
|
||||
Reference in New Issue
Block a user