feat(FN-1491): add centralized agent health status utility
- Create agentHealth.tsx utility with consistent health status logic - Update AgentDetailView, AgentListModal, and AgentsView to use centralized utility - Add comprehensive tests for health status edge cases - Update agents.md documentation with health status reference - Add changeset for @gsxdsm/fusion patch release
This commit is contained in:
@@ -11,6 +11,7 @@ import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||
|
||||
/**
|
||||
* Simple className utility - joins class names conditionally
|
||||
@@ -133,6 +134,18 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
void loadAgent();
|
||||
}, [loadAgent]);
|
||||
|
||||
// Poll for agent updates to keep health status fresh (every 30 seconds)
|
||||
// This ensures health badges stay current while the detail view is open
|
||||
useEffect(() => {
|
||||
const pollInterval = setInterval(() => {
|
||||
void loadAgent();
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [loadAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agent?.taskId) {
|
||||
void loadLogs();
|
||||
@@ -202,30 +215,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
}
|
||||
};
|
||||
|
||||
// Use centralized health status utility for consistent labels across all views
|
||||
const getHealthStatus = () => {
|
||||
if (!agent) return { label: "Unknown", color: "var(--text-muted, #8b949e)" };
|
||||
if (agent.state === "terminated") {
|
||||
return { label: "Terminated", color: "var(--state-error-text, #f85149)" };
|
||||
}
|
||||
if (agent.state === "error") {
|
||||
return { label: agent.lastError ?? "Error", color: "var(--state-error-text, #f85149)" };
|
||||
}
|
||||
if (agent.state === "paused") {
|
||||
return { label: agent.pauseReason ? `Paused: ${agent.pauseReason}` : "Paused", color: "var(--state-paused-text, #e3b541)" };
|
||||
}
|
||||
if (agent.state === "running") {
|
||||
return { label: "Running", color: "var(--state-active-text, #3fb950)" };
|
||||
}
|
||||
if (!agent.lastHeartbeatAt) {
|
||||
return { label: agent.state === "active" ? "Starting..." : "Idle", color: "var(--state-idle-text, #8b949e)" };
|
||||
}
|
||||
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const timeoutMs = (agent as any).runtimeConfig?.heartbeatTimeoutMs ?? 60000;
|
||||
if (elapsed > timeoutMs) {
|
||||
return { label: "Unresponsive", color: "var(--state-error-text, #f85149)" };
|
||||
}
|
||||
return { label: "Healthy", color: "var(--state-active-text, #3fb950)" };
|
||||
return getAgentHealthStatus(agent);
|
||||
};
|
||||
|
||||
const copyAgentId = () => {
|
||||
@@ -274,8 +267,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="badge" style={{ color: health.color }}>
|
||||
{health.label === "Healthy" && <Heart size={12} />}
|
||||
{health.label === "Unresponsive" && <Activity size={12} />}
|
||||
{health.icon}
|
||||
{health.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { X, Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot,
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||
|
||||
interface AgentListModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -80,6 +81,20 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
}
|
||||
}, [isOpen, loadAgents]);
|
||||
|
||||
// Poll for agent updates to keep health statuses fresh (every 30 seconds)
|
||||
// This ensures health badges stay current while the modal is open
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
void loadAgents();
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [isOpen, loadAgents]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newAgentName.trim()) return;
|
||||
try {
|
||||
@@ -143,29 +158,10 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "🤖";
|
||||
|
||||
// Use centralized health status utility for consistent labels across all views
|
||||
// This fixes the previous hardcoded 60s timeout that was inconsistent with other views
|
||||
const getHealthStatus = (agent: Agent): { label: string; icon: JSX.Element; color: string } => {
|
||||
if (agent.state === "terminated") {
|
||||
return { label: "Terminated", icon: <Square size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "error") {
|
||||
return { label: agent.lastError ?? "Error", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "running") {
|
||||
return { label: "Running", icon: <Activity size={14} />, color: "var(--state-active-text)" };
|
||||
}
|
||||
if (agent.state === "paused") {
|
||||
return { label: agent.pauseReason ?? "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
|
||||
}
|
||||
if (!agent.lastHeartbeatAt) {
|
||||
return { label: agent.state === "active" ? "Starting..." : "Idle", icon: <Bot size={14} />, color: "var(--text-secondary)" };
|
||||
}
|
||||
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const timeoutMs = 60000; // 60 second timeout
|
||||
if (elapsed > timeoutMs) {
|
||||
return { label: "Unresponsive", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
return { label: "Healthy", icon: <Heart size={14} />, color: "var(--state-active-text)" };
|
||||
return getAgentHealthStatus(agent);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { AgentNode } from "../hooks/useAgentHierarchy";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { AgentImportModal } from "./AgentImportModal";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||
|
||||
export interface AgentsViewProps {
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
@@ -286,6 +287,18 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
};
|
||||
}, [projectId, loadAgents]);
|
||||
|
||||
// Poll for agent updates to keep health statuses fresh (every 30 seconds)
|
||||
// This ensures health badges stay current while the view is open
|
||||
useEffect(() => {
|
||||
const pollInterval = setInterval(() => {
|
||||
void loadAgents();
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [loadAgents]);
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
@@ -364,32 +377,9 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "🤖";
|
||||
|
||||
// Use centralized health status utility for consistent labels across all views
|
||||
const getHealthStatus = (agent: Agent): { label: string; icon: JSX.Element; color: string } => {
|
||||
if (agent.state === "terminated") {
|
||||
return { label: "Terminated", icon: <Square size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "error") {
|
||||
return { label: agent.lastError ?? "Error", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
if (agent.state === "paused") {
|
||||
return { label: agent.pauseReason ? `Paused: ${agent.pauseReason}` : "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
|
||||
}
|
||||
if (agent.state === "running") {
|
||||
return { label: "Running", icon: <Activity size={14} />, color: "var(--state-active-text)" };
|
||||
}
|
||||
if (!agent.lastHeartbeatAt) {
|
||||
return { label: agent.state === "active" ? "Starting..." : "Idle", icon: <Bot size={14} />, color: "var(--text-secondary)" };
|
||||
}
|
||||
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const runtimeConfig = agent.runtimeConfig as Record<string, unknown> | undefined;
|
||||
const configuredTimeout = typeof runtimeConfig?.heartbeatTimeoutMs === "number"
|
||||
? runtimeConfig.heartbeatTimeoutMs
|
||||
: 60000;
|
||||
if (elapsed > configuredTimeout) {
|
||||
return { label: "Unresponsive", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
return { label: "Healthy", icon: <Heart size={14} />, color: "var(--state-active-text)" };
|
||||
return getAgentHealthStatus(agent);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
369
packages/dashboard/app/utils/agentHealth.test.tsx
Normal file
369
packages/dashboard/app/utils/agentHealth.test.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getAgentHealthStatus, getAgentHealthColorVar } from "./agentHealth";
|
||||
import type { Agent } from "../api";
|
||||
|
||||
// Mock Date.now to get deterministic elapsed time calculations
|
||||
const FIXED_NOW = new Date("2026-04-10T12:00:00.000Z").getTime();
|
||||
|
||||
function makeAgent(overrides: Partial<Pick<Agent, "state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig">> = {}): Pick<Agent, "state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig"> {
|
||||
return {
|
||||
state: "idle",
|
||||
lastHeartbeatAt: undefined,
|
||||
lastError: undefined,
|
||||
pauseReason: undefined,
|
||||
runtimeConfig: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getAgentHealthStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(FIXED_NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Terminal states ──────────────────────────────────────────────────────
|
||||
|
||||
describe("terminated state", () => {
|
||||
it('returns "Terminated" for terminated agents', () => {
|
||||
const agent = makeAgent({ state: "terminated" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Terminated");
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for terminated agents", () => {
|
||||
const agent = makeAgent({
|
||||
state: "terminated",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1000).toISOString(),
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Terminated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error state", () => {
|
||||
it('returns "Error" for error agents without lastError', () => {
|
||||
const agent = makeAgent({ state: "error" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Error");
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
it("uses lastError as label when available", () => {
|
||||
const agent = makeAgent({ state: "error", lastError: "Agent crashed" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Agent crashed");
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for error agents", () => {
|
||||
const agent = makeAgent({
|
||||
state: "error",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1000).toISOString(),
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("paused state", () => {
|
||||
it('returns "Paused" for paused agents without pauseReason', () => {
|
||||
const agent = makeAgent({ state: "paused" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Paused");
|
||||
expect(status.color).toBe("var(--state-paused-text)");
|
||||
});
|
||||
|
||||
it("includes pauseReason in label when available", () => {
|
||||
const agent = makeAgent({ state: "paused", pauseReason: "User requested" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Paused: User requested");
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for paused agents", () => {
|
||||
const agent = makeAgent({
|
||||
state: "paused",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1000).toISOString(),
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Paused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("running state", () => {
|
||||
it('returns "Running" for running agents', () => {
|
||||
const agent = makeAgent({ state: "running" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it("ignores heartbeat data for running agents", () => {
|
||||
const agent = makeAgent({
|
||||
state: "running",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // 100s ago - would be "unresponsive" without this
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Running");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Heartbeat monitoring disabled ──────────────────────────────────────────
|
||||
|
||||
describe("heartbeat monitoring disabled", () => {
|
||||
it('returns "Disabled" when runtimeConfig.enabled === false', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
runtimeConfig: { enabled: false },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
expect(status.color).toBe("var(--text-secondary)");
|
||||
});
|
||||
|
||||
it('returns "Disabled" even with stale heartbeat data', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(), // very stale
|
||||
runtimeConfig: { enabled: false, heartbeatTimeoutMs: 60000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
});
|
||||
|
||||
it('returns "Disabled" for idle agents with monitoring disabled', () => {
|
||||
const agent = makeAgent({
|
||||
state: "idle",
|
||||
runtimeConfig: { enabled: false },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Disabled");
|
||||
});
|
||||
});
|
||||
|
||||
// ── No heartbeat data ──────────────────────────────────────────────────────
|
||||
|
||||
describe("no heartbeat data", () => {
|
||||
it('returns "Starting..." for active agents with no lastHeartbeatAt', () => {
|
||||
const agent = makeAgent({ state: "active" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Starting...");
|
||||
expect(status.color).toBe("var(--text-secondary)");
|
||||
});
|
||||
|
||||
it('returns "Idle" for non-active agents with no lastHeartbeatAt', () => {
|
||||
const agent = makeAgent({ state: "idle" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Idle");
|
||||
expect(status.color).toBe("var(--text-secondary)");
|
||||
});
|
||||
|
||||
it('returns "Idle" for terminated agents without heartbeat (edge case)', () => {
|
||||
// Although terminated state takes precedence, testing the fallback
|
||||
const agent = makeAgent({ state: "idle", lastHeartbeatAt: undefined });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Idle");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Healthy vs Unresponsive ───────────────────────────────────────────────
|
||||
|
||||
describe("heartbeat freshness", () => {
|
||||
it('returns "Healthy" when heartbeat is fresh (within timeout)', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(), // 30s ago, well within 60s timeout
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
expect(status.color).toBe("var(--state-active-text)");
|
||||
});
|
||||
|
||||
it('returns "Healthy" when heartbeat is exactly at the timeout boundary', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(), // exactly 60s ago
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it('returns "Unresponsive" when heartbeat exceeds the timeout', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_001).toISOString(), // just over 60s ago
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
expect(status.color).toBe("var(--state-error-text)");
|
||||
});
|
||||
|
||||
it("uses per-agent heartbeatTimeoutMs when configured", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
// 90s ago - would be unresponsive with default 60s, but within 120s timeout
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 90_000).toISOString(),
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("marks as unresponsive when exceeding per-agent timeout", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
// 60s ago - would be healthy with default 60s, but exceeds 30s custom timeout
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(),
|
||||
runtimeConfig: { heartbeatTimeoutMs: 30_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-agent timeout overrides ────────────────────────────────────────────
|
||||
|
||||
describe("per-agent timeout overrides", () => {
|
||||
it("uses default 60s timeout when no runtimeConfig", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 59_000).toISOString(), // 59s ago
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("uses default 60s timeout when runtimeConfig exists but no heartbeatTimeoutMs", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 59_000).toISOString(),
|
||||
runtimeConfig: { maxConcurrentRuns: 2 }, // has other config, but no timeout
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("handles custom timeout of 30 seconds", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 45_000).toISOString(), // 45s ago
|
||||
runtimeConfig: { heartbeatTimeoutMs: 30_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
});
|
||||
|
||||
it("handles custom timeout of 120 seconds", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 90_000).toISOString(), // 90s ago
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("handles very short timeout of 5 seconds", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 6_000).toISOString(), // 6s ago
|
||||
runtimeConfig: { heartbeatTimeoutMs: 5_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edge cases ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles null runtimeConfig gracefully", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
|
||||
runtimeConfig: null as unknown as undefined,
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("handles empty runtimeConfig object", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
|
||||
runtimeConfig: {},
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("treats runtimeConfig.enabled as true when undefined", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // stale
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120_000 }, // no enabled field
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy"); // monitoring is enabled by default
|
||||
});
|
||||
|
||||
it("treats runtimeConfig.enabled === true as enabled", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // stale
|
||||
runtimeConfig: { enabled: true, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it("returns consistent icons for all states", () => {
|
||||
const testCases: Array<{ agent: ReturnType<typeof makeAgent>; expectedIconType: string }> = [
|
||||
{ agent: makeAgent({ state: "terminated" }), expectedIconType: "Square" },
|
||||
{ agent: makeAgent({ state: "error" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "paused" }), expectedIconType: "Pause" },
|
||||
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
|
||||
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Bot" },
|
||||
// Active with recent heartbeat should show "Healthy" (Heart icon)
|
||||
{ agent: makeAgent({ state: "active", lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString() }), expectedIconType: "Heart" },
|
||||
];
|
||||
|
||||
testCases.forEach(({ agent, expectedIconType }) => {
|
||||
const status = getAgentHealthStatus(agent);
|
||||
// lucide icons have displayName property
|
||||
const iconType = (status.icon as any).type?.displayName ?? (status.icon as any).type?.name;
|
||||
expect(iconType).toBe(expectedIconType);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentHealthColorVar", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(FIXED_NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("extracts CSS variable name from health status color", () => {
|
||||
const agent = makeAgent({ state: "terminated" });
|
||||
const colorVar = getAgentHealthColorVar(agent);
|
||||
expect(colorVar).toBe("--state-error-text");
|
||||
});
|
||||
|
||||
it("returns full color for non-variable colors (fallback)", () => {
|
||||
// This shouldn't happen in practice, but testing the fallback
|
||||
const agent = makeAgent({ state: "terminated" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
// The function should return the variable name in var() format
|
||||
expect(getAgentHealthColorVar(agent)).toBe(status.color.replace(/var\((--[^)]+)\)/, "$1"));
|
||||
});
|
||||
});
|
||||
139
packages/dashboard/app/utils/agentHealth.tsx
Normal file
139
packages/dashboard/app/utils/agentHealth.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { JSX } from "react";
|
||||
import { Bot, Heart, Activity, Pause, Square } from "lucide-react";
|
||||
import type { Agent, AgentState } from "../api";
|
||||
|
||||
/** Default heartbeat timeout when not configured per-agent */
|
||||
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Shape of the health status returned by getAgentHealthStatus */
|
||||
export interface AgentHealthStatus {
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the heartbeat timeout from agent runtimeConfig.
|
||||
* Returns undefined if not set or if monitoring is disabled.
|
||||
*/
|
||||
function getHeartbeatTimeoutMs(runtimeConfig?: Record<string, unknown>): number | undefined {
|
||||
if (!runtimeConfig) return undefined;
|
||||
if (runtimeConfig.enabled === false) return undefined;
|
||||
if (typeof runtimeConfig.heartbeatTimeoutMs !== "number") return undefined;
|
||||
return runtimeConfig.heartbeatTimeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if heartbeat monitoring is enabled for the agent.
|
||||
* Returns false if runtimeConfig.enabled === false, true otherwise.
|
||||
*/
|
||||
function isHeartbeatEnabled(runtimeConfig?: Record<string, unknown>): boolean {
|
||||
if (!runtimeConfig) return true;
|
||||
if (typeof runtimeConfig.enabled === "boolean") return runtimeConfig.enabled;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a single canonical health status for an agent based on its
|
||||
* state, runtimeConfig, and last heartbeat timestamp.
|
||||
*
|
||||
* Health labels (in priority order):
|
||||
* - "Terminated" — agent.state === "terminated"
|
||||
* - "Error" — agent.state === "error" (uses lastError if available)
|
||||
* - "Paused" — agent.state === "paused" (uses pauseReason if available)
|
||||
* - "Running" — agent.state === "running"
|
||||
* - "Disabled" — runtimeConfig.enabled === false
|
||||
* - "Starting..." — state === "active" && no lastHeartbeatAt
|
||||
* - "Idle" — state !== "active" && no lastHeartbeatAt
|
||||
* - "Healthy" — heartbeat is fresh within the configured timeout
|
||||
* - "Unresponsive" — heartbeat exceeded the configured timeout
|
||||
*
|
||||
* @param agent - The agent object (partial Agent shape is accepted)
|
||||
* @returns A health status object with label, icon, and color
|
||||
*/
|
||||
export function getAgentHealthStatus(agent: Pick<Agent, "state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig">): AgentHealthStatus {
|
||||
const { state, lastHeartbeatAt, lastError, pauseReason, runtimeConfig } = agent;
|
||||
|
||||
// Terminal states - these always take precedence
|
||||
if (state === "terminated") {
|
||||
return {
|
||||
label: "Terminated",
|
||||
icon: <Square size={14} />,
|
||||
color: "var(--state-error-text)",
|
||||
};
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return {
|
||||
label: lastError ?? "Error",
|
||||
icon: <Activity size={14} />,
|
||||
color: "var(--state-error-text)",
|
||||
};
|
||||
}
|
||||
|
||||
if (state === "paused") {
|
||||
const label = pauseReason ? `Paused: ${pauseReason}` : "Paused";
|
||||
return {
|
||||
label,
|
||||
icon: <Pause size={14} />,
|
||||
color: "var(--state-paused-text)",
|
||||
};
|
||||
}
|
||||
|
||||
if (state === "running") {
|
||||
return {
|
||||
label: "Running",
|
||||
icon: <Activity size={14} />,
|
||||
color: "var(--state-active-text)",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if heartbeat monitoring is enabled
|
||||
if (!isHeartbeatEnabled(runtimeConfig)) {
|
||||
return {
|
||||
label: "Disabled",
|
||||
icon: <Bot size={14} />,
|
||||
color: "var(--text-secondary)",
|
||||
};
|
||||
}
|
||||
|
||||
// No heartbeat data yet
|
||||
if (!lastHeartbeatAt) {
|
||||
return {
|
||||
label: state === "active" ? "Starting..." : "Idle",
|
||||
icon: <Bot size={14} />,
|
||||
color: "var(--text-secondary)",
|
||||
};
|
||||
}
|
||||
|
||||
// Compute elapsed time since last heartbeat
|
||||
const lastHeartbeat = new Date(lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const timeoutMs = getHeartbeatTimeoutMs(runtimeConfig) ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
||||
|
||||
if (elapsed > timeoutMs) {
|
||||
return {
|
||||
label: "Unresponsive",
|
||||
icon: <Activity size={14} />,
|
||||
color: "var(--state-error-text)",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Healthy",
|
||||
icon: <Heart size={14} />,
|
||||
color: "var(--state-active-text)",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CSS variable name for the health color.
|
||||
* Useful when you need the raw CSS variable name for custom styling.
|
||||
*/
|
||||
export function getAgentHealthColorVar(agent: Pick<Agent, "state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig">): string {
|
||||
const status = getAgentHealthStatus(agent);
|
||||
// Extract the CSS variable name from the color string
|
||||
// e.g., "var(--state-error-text)" -> "--state-error-text"
|
||||
const match = status.color.match(/var\((--[^)]+)\)/);
|
||||
return match ? match[1] : status.color;
|
||||
}
|
||||
Reference in New Issue
Block a user