feat(FN-2280): improve agent heartbeat interval selection and validation
- Expand heartbeat interval presets to enforce a 5-minute minimum and include extended 48h/72h/1w options - Add custom heartbeat entry in AgentsView with typed minute input, save/cancel actions, and validation/clamp behavior - Show the Custom option only when the current interval is a preset, while preserving explicit custom interval labels when needed - Update dashboard tests and heartbeat utility tests to cover new preset boundaries, custom option behavior, and health staleness expectations - Document the 5-minute minimum heartbeat clamp behavior in agents documentation
This commit is contained in:
@@ -19,6 +19,8 @@ import {
|
||||
formatHeartbeatInterval,
|
||||
getHeartbeatIntervalOptions,
|
||||
resolveHeartbeatIntervalMs,
|
||||
MIN_HEARTBEAT_INTERVAL_MS,
|
||||
HEARTBEAT_INTERVAL_PRESETS,
|
||||
} from "../utils/heartbeatIntervals";
|
||||
import { isEphemeralAgent } from "@fusion/core";
|
||||
|
||||
@@ -279,6 +281,10 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const roleSelectRef = useRef<HTMLSelectElement>(null);
|
||||
const [showSystemAgents, setShowSystemAgents] = useState(false);
|
||||
const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState<string | null>(null);
|
||||
/** Agent ID currently showing custom heartbeat input */
|
||||
const [customHeartbeatAgentId, setCustomHeartbeatAgentId] = useState<string | null>(null);
|
||||
/** Custom minutes input value for each agent */
|
||||
const [customHeartbeatMinutes, setCustomHeartbeatMinutes] = useState<Record<string, string>>({});
|
||||
|
||||
const hierarchy = useAgentHierarchy(agents, projectId);
|
||||
|
||||
@@ -442,6 +448,16 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
};
|
||||
|
||||
const handleHeartbeatIntervalChange = async (agent: Agent, newIntervalMs: number) => {
|
||||
// Clear custom input state when selecting a preset
|
||||
if (customHeartbeatAgentId === agent.id) {
|
||||
setCustomHeartbeatAgentId(null);
|
||||
setCustomHeartbeatMinutes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[agent.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setUpdatingHeartbeatAgentId(agent.id);
|
||||
try {
|
||||
await updateAgent(
|
||||
@@ -463,6 +479,108 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle saving custom heartbeat interval from typed minutes input.
|
||||
* Validation behavior:
|
||||
* - Empty value: do not save; show validation toast
|
||||
* - Non-numeric value: do not save; show validation toast
|
||||
* - Value <= 0: do not save; show validation toast
|
||||
* - Value 1-4: save as 5 minutes (300000 ms) and show clamp-info toast
|
||||
* - Value >= 5: save exact minute value converted to ms
|
||||
*/
|
||||
const handleCustomHeartbeatSave = async (agent: Agent) => {
|
||||
const inputValue = customHeartbeatMinutes[agent.id] ?? "";
|
||||
|
||||
// Validate: empty value
|
||||
if (inputValue.trim() === "") {
|
||||
addToast("Please enter a heartbeat interval in minutes", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: non-numeric value
|
||||
const minutes = Number(inputValue);
|
||||
if (isNaN(minutes)) {
|
||||
addToast("Heartbeat interval must be a valid number", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: zero or negative
|
||||
if (minutes <= 0) {
|
||||
addToast("Heartbeat interval must be greater than 0", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle values 1-4: clamp to 5 minutes
|
||||
if (minutes >= 1 && minutes < 5) {
|
||||
setUpdatingHeartbeatAgentId(agent.id);
|
||||
try {
|
||||
await updateAgent(
|
||||
agent.id,
|
||||
{
|
||||
runtimeConfig: {
|
||||
...(agent.runtimeConfig ?? {}),
|
||||
heartbeatIntervalMs: MIN_HEARTBEAT_INTERVAL_MS,
|
||||
},
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
addToast(`Heartbeat interval set to 5 minutes (minimum). ${minutes} minute${minutes !== 1 ? "s" : ""} was below the 5-minute minimum.`, "success");
|
||||
setCustomHeartbeatAgentId(null);
|
||||
setCustomHeartbeatMinutes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[agent.id];
|
||||
return next;
|
||||
});
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to update heartbeat interval: ${err.message}`, "error");
|
||||
} finally {
|
||||
setUpdatingHeartbeatAgentId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle values >= 5: save exact minute value
|
||||
const intervalMs = Math.round(minutes * 60_000);
|
||||
setUpdatingHeartbeatAgentId(agent.id);
|
||||
try {
|
||||
await updateAgent(
|
||||
agent.id,
|
||||
{
|
||||
runtimeConfig: {
|
||||
...(agent.runtimeConfig ?? {}),
|
||||
heartbeatIntervalMs: intervalMs,
|
||||
},
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
addToast(`Heartbeat interval updated to ${formatHeartbeatInterval(intervalMs)} for ${agent.name}`, "success");
|
||||
setCustomHeartbeatAgentId(null);
|
||||
setCustomHeartbeatMinutes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[agent.id];
|
||||
return next;
|
||||
});
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to update heartbeat interval: ${err.message}`, "error");
|
||||
} finally {
|
||||
setUpdatingHeartbeatAgentId(null);
|
||||
}
|
||||
};
|
||||
|
||||
/** Handle selecting custom option from dropdown */
|
||||
const handleSelectCustomHeartbeat = (agent: Agent) => {
|
||||
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
|
||||
// Convert ms to minutes for the input field
|
||||
const currentMinutes = Math.round(configuredIntervalMs / 60_000);
|
||||
setCustomHeartbeatAgentId(agent.id);
|
||||
setCustomHeartbeatMinutes((prev) => ({
|
||||
...prev,
|
||||
[agent.id]: String(currentMinutes),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCloseDetail = useCallback(() => {
|
||||
setSelectedAgentId(null);
|
||||
}, []);
|
||||
@@ -806,19 +924,88 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
)}
|
||||
<div className="agent-heartbeat-control">
|
||||
<span className="text-secondary">Heartbeat:</span>
|
||||
<select
|
||||
className="select agent-heartbeat-select"
|
||||
value={configuredIntervalMs}
|
||||
onChange={(e) => void handleHeartbeatIntervalChange(agent, Number(e.target.value))}
|
||||
disabled={isUpdatingHeartbeat}
|
||||
aria-label={`Set heartbeat interval for ${agent.name}`}
|
||||
>
|
||||
{heartbeatOptions.map((preset) => (
|
||||
<option key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{customHeartbeatAgentId === agent.id ? (
|
||||
// Custom input mode
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className="input agent-heartbeat-custom-input"
|
||||
value={customHeartbeatMinutes[agent.id] ?? ""}
|
||||
onChange={(e) => setCustomHeartbeatMinutes((prev) => ({
|
||||
...prev,
|
||||
[agent.id]: e.target.value,
|
||||
}))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleCustomHeartbeatSave(agent);
|
||||
} else if (e.key === "Escape") {
|
||||
setCustomHeartbeatAgentId(null);
|
||||
setCustomHeartbeatMinutes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[agent.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={isUpdatingHeartbeat}
|
||||
aria-label={`Custom heartbeat interval in minutes for ${agent.name}`}
|
||||
/>
|
||||
<span className="text-secondary">min</span>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleCustomHeartbeatSave(agent)}
|
||||
disabled={isUpdatingHeartbeat}
|
||||
title="Save custom interval"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => {
|
||||
setCustomHeartbeatAgentId(null);
|
||||
setCustomHeartbeatMinutes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[agent.id];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
disabled={isUpdatingHeartbeat}
|
||||
title="Cancel custom interval"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Preset selection mode
|
||||
<>
|
||||
<select
|
||||
className="select agent-heartbeat-select"
|
||||
value={configuredIntervalMs}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "__custom__") {
|
||||
handleSelectCustomHeartbeat(agent);
|
||||
} else {
|
||||
void handleHeartbeatIntervalChange(agent, Number(value));
|
||||
}
|
||||
}}
|
||||
disabled={isUpdatingHeartbeat}
|
||||
aria-label={`Set heartbeat interval for ${agent.name}`}
|
||||
>
|
||||
{heartbeatOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
{/* Only show "Custom..." if current value is a preset; if it's already custom, it's already in the list */}
|
||||
{HEARTBEAT_INTERVAL_PRESETS.some((p) => p.value === configuredIntervalMs) && (
|
||||
<option value="__custom__">Custom...</option>
|
||||
)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{isUpdatingHeartbeat && <span className="agent-heartbeat-saving text-secondary">Saving…</span>}
|
||||
{agent.lastHeartbeatAt && (() => {
|
||||
const lastAt = new Date(agent.lastHeartbeatAt);
|
||||
|
||||
@@ -183,15 +183,30 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows heartbeat interval control on agent cards", async () => {
|
||||
it("shows heartbeat interval control on agent cards with 5m minimum presets", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText("30s").length).toBeGreaterThan(0);
|
||||
expect(screen.getByDisplayValue("30s")).toBeTruthy();
|
||||
// Agent 2 has heartbeatIntervalMs: 30000 (30s) which should be clamped to 5m
|
||||
expect(screen.getByDisplayValue("5m")).toBeTruthy();
|
||||
|
||||
// Verify all expected presets are present
|
||||
const select = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
|
||||
const options = Array.from(select.options).map(o => o.text);
|
||||
expect(options).toContain("5m");
|
||||
expect(options).toContain("48h");
|
||||
expect(options).toContain("72h");
|
||||
expect(options).toContain("1w");
|
||||
|
||||
// Verify old sub-5m presets are NOT present
|
||||
expect(options).not.toContain("1s");
|
||||
expect(options).not.toContain("5s");
|
||||
expect(options).not.toContain("10s");
|
||||
expect(options).not.toContain("30s");
|
||||
expect(options).not.toContain("1m");
|
||||
});
|
||||
|
||||
it("uses the system default heartbeat interval when runtime config is unset", async () => {
|
||||
@@ -220,28 +235,22 @@ describe("AgentsView", () => {
|
||||
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Change from 5m (clamped from 30s) to 15m
|
||||
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
|
||||
fireEvent.change(intervalSelect, { target: { value: "60000" } });
|
||||
fireEvent.change(intervalSelect, { target: { value: "900000" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-002",
|
||||
expect.objectContaining({
|
||||
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 60000 }),
|
||||
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 900000 }),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a custom heartbeat option when configured interval is not a preset", async () => {
|
||||
mockFetchAgents.mockResolvedValue([
|
||||
{
|
||||
...mockAgents[1],
|
||||
runtimeConfig: { heartbeatIntervalMs: 65_000 },
|
||||
},
|
||||
]);
|
||||
|
||||
it("shows Custom... option in dropdown that reveals typed input", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -249,9 +258,197 @@ describe("AgentsView", () => {
|
||||
});
|
||||
|
||||
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
|
||||
expect(intervalSelect.value).toBe("65000");
|
||||
expect(intervalSelect.options[intervalSelect.selectedIndex]?.text).toBe("1m (custom)");
|
||||
expect(screen.getByRole("option", { name: "1m (custom)" })).toBeTruthy();
|
||||
|
||||
// Change to Custom... option
|
||||
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show custom input with minutes field
|
||||
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("can enter custom minutes value and save it", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select Custom... option
|
||||
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
|
||||
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Enter 7 minutes
|
||||
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
|
||||
fireEvent.change(customInput, { target: { value: "7" } });
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
// Should save 7 minutes = 420000 ms
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-002",
|
||||
expect.objectContaining({
|
||||
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 420000 }),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps custom value 1-4 minutes to 5 minutes with info toast", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select Custom... option
|
||||
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
|
||||
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Enter 3 minutes
|
||||
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
|
||||
fireEvent.change(customInput, { target: { value: "3" } });
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
// Should save 5 minutes (minimum) = 300000 ms
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-002",
|
||||
expect.objectContaining({
|
||||
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 300000 }),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
// Should show info toast about clamping
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("5 minutes (minimum)"),
|
||||
"success",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not save when custom input is empty", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select Custom... option
|
||||
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
|
||||
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Clear the pre-filled value to empty
|
||||
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
|
||||
fireEvent.change(customInput, { target: { value: "" } });
|
||||
|
||||
// Wait for state to update
|
||||
await waitFor(() => {
|
||||
expect((customInput as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
// Click Save with empty input
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Should not call updateAgent
|
||||
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||
// Should show error toast
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("enter a heartbeat interval"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not save when custom input is non-numeric", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select Custom... option
|
||||
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
|
||||
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Clear and enter non-numeric value
|
||||
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
|
||||
fireEvent.change(customInput, { target: { value: "abc" } });
|
||||
|
||||
// Wait for state to update
|
||||
await waitFor(() => {
|
||||
expect((customInput as HTMLInputElement).value).toBe("abc");
|
||||
});
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Should not call updateAgent
|
||||
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||
// Should show error toast
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("valid number"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not save when custom input is zero or negative", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select Custom... option
|
||||
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2");
|
||||
fireEvent.change(intervalSelect, { target: { value: "__custom__" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Enter 0
|
||||
const customInput = screen.getByLabelText("Custom heartbeat interval in minutes for Test Agent 2");
|
||||
fireEvent.change(customInput, { target: { value: "0" } });
|
||||
|
||||
// Wait for state to update
|
||||
await waitFor(() => {
|
||||
expect((customInput as HTMLInputElement).value).toBe("0");
|
||||
});
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// Should not call updateAgent
|
||||
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||
// Should show error toast
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("greater than 0"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows refresh button", async () => {
|
||||
|
||||
@@ -242,8 +242,8 @@ describe("getAgentHealthStatus", () => {
|
||||
it('returns "Unresponsive" when heartbeat exceeds the timeout with periodic heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 60_001).toISOString(), // just over 60s ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000 }, // periodic heartbeat configured
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 12 * 60 * 1000 - 1).toISOString(), // just over 12 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Unresponsive");
|
||||
@@ -288,12 +288,12 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
|
||||
it("clamps invalid intervals (0/negative) to the scheduler minimum (1s)", () => {
|
||||
// 0/-5000 clamp to 1000ms → threshold falls back to the 60s floor.
|
||||
// A heartbeat 120s old is stale.
|
||||
it("clamps invalid intervals (0/negative) to the dashboard minimum (5m)", () => {
|
||||
// 0 clamp to 300000ms (5m minimum) → threshold = max(300000 × 2, 60000) = 600000ms (10 minutes).
|
||||
// A heartbeat 11 minutes old is stale.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 120_000).toISOString(),
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 660_000).toISOString(), // 11 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 0 },
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
@@ -316,10 +316,12 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
|
||||
it("tips to Unresponsive past the floor", () => {
|
||||
// 6 minute interval → threshold = max(6 × 60s × 2, 60s floor) = max(12 min, 1 min) = 12 minutes.
|
||||
// A heartbeat 13 minutes old exceeds the 12-minute threshold.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 61_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 10_000 },
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
@@ -373,8 +375,8 @@ describe("getAgentHealthStatus", () => {
|
||||
name: "unresponsive",
|
||||
agent: makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 120_000).toISOString(),
|
||||
runtimeConfig: { heartbeatIntervalMs: 30_000 },
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
|
||||
}),
|
||||
expectedLabel: "Unresponsive",
|
||||
expectedStateDerived: false,
|
||||
@@ -434,12 +436,12 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
|
||||
it("ignores runtimeConfig.enabled and uses interval-based staleness", () => {
|
||||
// 30s interval → 60s threshold. 100s elapsed is stale regardless of any
|
||||
// 6 minute interval → 12 minute threshold. 13 minutes elapsed is stale regardless of any
|
||||
// legacy enabled flag or per-run timeout.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(),
|
||||
runtimeConfig: { enabled: true, heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 120_000 },
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
|
||||
runtimeConfig: { enabled: true, heartbeatIntervalMs: 6 * 60 * 1000, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
|
||||
193
packages/dashboard/app/utils/heartbeatIntervals.test.ts
Normal file
193
packages/dashboard/app/utils/heartbeatIntervals.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
HEARTBEAT_INTERVAL_PRESETS,
|
||||
MIN_HEARTBEAT_INTERVAL_MS,
|
||||
DEFAULT_HEARTBEAT_INTERVAL_MS,
|
||||
formatHeartbeatInterval,
|
||||
resolveHeartbeatIntervalMs,
|
||||
getHeartbeatIntervalOptions,
|
||||
} from "./heartbeatIntervals";
|
||||
|
||||
describe("HEARTBEAT_INTERVAL_PRESETS", () => {
|
||||
it("starts at 5 minutes (300000ms)", () => {
|
||||
expect(HEARTBEAT_INTERVAL_PRESETS[0].value).toBe(300000);
|
||||
expect(HEARTBEAT_INTERVAL_PRESETS[0].label).toBe("5m");
|
||||
});
|
||||
|
||||
it("includes 48h preset", () => {
|
||||
const preset = HEARTBEAT_INTERVAL_PRESETS.find((p) => p.label === "48h");
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset?.value).toBe(172800000);
|
||||
});
|
||||
|
||||
it("includes 72h preset", () => {
|
||||
const preset = HEARTBEAT_INTERVAL_PRESETS.find((p) => p.label === "72h");
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset?.value).toBe(259200000);
|
||||
});
|
||||
|
||||
it("includes 1w preset", () => {
|
||||
const preset = HEARTBEAT_INTERVAL_PRESETS.find((p) => p.label === "1w");
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset?.value).toBe(604800000);
|
||||
});
|
||||
|
||||
it("does not include any presets below 5 minutes", () => {
|
||||
const allBelow5m = HEARTBEAT_INTERVAL_PRESETS.filter((p) => p.value < 300000);
|
||||
expect(allBelow5m).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is sorted in ascending order by value", () => {
|
||||
for (let i = 1; i < HEARTBEAT_INTERVAL_PRESETS.length; i++) {
|
||||
expect(HEARTBEAT_INTERVAL_PRESETS[i].value).toBeGreaterThan(
|
||||
HEARTBEAT_INTERVAL_PRESETS[i - 1].value,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIN_HEARTBEAT_INTERVAL_MS", () => {
|
||||
it("is 5 minutes (300000ms)", () => {
|
||||
expect(MIN_HEARTBEAT_INTERVAL_MS).toBe(300000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHeartbeatInterval", () => {
|
||||
it("formats milliseconds below 1000", () => {
|
||||
expect(formatHeartbeatInterval(500)).toBe("500ms");
|
||||
});
|
||||
|
||||
it("formats seconds", () => {
|
||||
expect(formatHeartbeatInterval(1000)).toBe("1s");
|
||||
expect(formatHeartbeatInterval(30000)).toBe("30s");
|
||||
expect(formatHeartbeatInterval(45000)).toBe("45s");
|
||||
});
|
||||
|
||||
it("formats minutes", () => {
|
||||
expect(formatHeartbeatInterval(60000)).toBe("1m");
|
||||
expect(formatHeartbeatInterval(300000)).toBe("5m");
|
||||
expect(formatHeartbeatInterval(2700000)).toBe("45m");
|
||||
});
|
||||
|
||||
it("formats hours", () => {
|
||||
expect(formatHeartbeatInterval(3600000)).toBe("1h");
|
||||
expect(formatHeartbeatInterval(7200000)).toBe("2h");
|
||||
expect(formatHeartbeatInterval(43200000)).toBe("12h");
|
||||
});
|
||||
|
||||
it("formats days", () => {
|
||||
expect(formatHeartbeatInterval(86400000)).toBe("1d");
|
||||
expect(formatHeartbeatInterval(172800000)).toBe("2d");
|
||||
expect(formatHeartbeatInterval(432000000)).toBe("5d");
|
||||
});
|
||||
|
||||
it("formats weeks", () => {
|
||||
expect(formatHeartbeatInterval(604800000)).toBe("1w");
|
||||
expect(formatHeartbeatInterval(1209600000)).toBe("2w");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHeartbeatIntervalMs", () => {
|
||||
it("returns default for non-number input", () => {
|
||||
expect(resolveHeartbeatIntervalMs(undefined)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs(null)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs("300000")).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs({})).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs([])).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
});
|
||||
|
||||
it("returns default for NaN or Infinity", () => {
|
||||
expect(resolveHeartbeatIntervalMs(NaN)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs(Infinity)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
expect(resolveHeartbeatIntervalMs(-Infinity)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
});
|
||||
|
||||
it("clamps values below 5 minutes to 5 minutes", () => {
|
||||
expect(resolveHeartbeatIntervalMs(0)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(1000)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(60000)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(299999)).toBe(300000);
|
||||
});
|
||||
|
||||
it("returns exact value for valid intervals >= 5 minutes", () => {
|
||||
expect(resolveHeartbeatIntervalMs(300000)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(600000)).toBe(600000);
|
||||
expect(resolveHeartbeatIntervalMs(3600000)).toBe(3600000);
|
||||
expect(resolveHeartbeatIntervalMs(172800000)).toBe(172800000);
|
||||
});
|
||||
|
||||
it("rounds floating point values", () => {
|
||||
expect(resolveHeartbeatIntervalMs(300001.7)).toBe(300002);
|
||||
expect(resolveHeartbeatIntervalMs(300001.3)).toBe(300001);
|
||||
});
|
||||
|
||||
it("clamps negative values to minimum", () => {
|
||||
expect(resolveHeartbeatIntervalMs(-1)).toBe(300000);
|
||||
expect(resolveHeartbeatIntervalMs(-60000)).toBe(300000);
|
||||
});
|
||||
|
||||
describe("legacy sub-5m values resolve to 5m", () => {
|
||||
it("1s legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(1000)).toBe(300000);
|
||||
});
|
||||
|
||||
it("5s legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(5000)).toBe(300000);
|
||||
});
|
||||
|
||||
it("10s legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(10000)).toBe(300000);
|
||||
});
|
||||
|
||||
it("30s legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(30000)).toBe(300000);
|
||||
});
|
||||
|
||||
it("1m legacy value resolves to 5m", () => {
|
||||
expect(resolveHeartbeatIntervalMs(60000)).toBe(300000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHeartbeatIntervalOptions", () => {
|
||||
it("returns all presets when interval matches a preset", () => {
|
||||
const options = getHeartbeatIntervalOptions(300000);
|
||||
expect(options).toEqual([...HEARTBEAT_INTERVAL_PRESETS]);
|
||||
});
|
||||
|
||||
it("adds custom option when interval does not match any preset", () => {
|
||||
const options = getHeartbeatIntervalOptions(650000);
|
||||
// Should have all presets plus a custom option
|
||||
expect(options.length).toBe(HEARTBEAT_INTERVAL_PRESETS.length + 1);
|
||||
// The custom option should be added and sorted in by value
|
||||
const customOption = options.find((o) => o.label.includes("(custom)"));
|
||||
expect(customOption?.value).toBe(650000);
|
||||
expect(customOption?.label).toBe("11m (custom)");
|
||||
});
|
||||
|
||||
it("sorts custom option into correct position by value", () => {
|
||||
// 48h is a preset, so no custom option added
|
||||
const optionsWithPreset = getHeartbeatIntervalOptions(172800000);
|
||||
expect(optionsWithPreset.length).toBe(HEARTBEAT_INTERVAL_PRESETS.length);
|
||||
expect(optionsWithPreset).toEqual([...HEARTBEAT_INTERVAL_PRESETS]);
|
||||
});
|
||||
|
||||
it("sorts custom option after 1w when custom value exceeds 1w", () => {
|
||||
// 500h is not a preset, should be added and sorted after 1w
|
||||
const options = getHeartbeatIntervalOptions(500 * 3600000);
|
||||
const customOption = options.find((o) => o.label.includes("(custom)"));
|
||||
expect(customOption).toBeDefined();
|
||||
// Custom option should be inserted at the end since 500h > 1w
|
||||
const customIndex = options.findIndex((o) => o.label.includes("(custom)"));
|
||||
expect(options[customIndex - 1].label).toBe("1w");
|
||||
});
|
||||
|
||||
it("handles custom intervals below the minimum", () => {
|
||||
// Even if a legacy custom value is below 5m, getHeartbeatIntervalOptions
|
||||
// should include it in the options (the resolver clamps when consuming)
|
||||
const options = getHeartbeatIntervalOptions(30000); // 30s - no longer a preset
|
||||
const customOption = options.find((o) => o.value === 30000);
|
||||
expect(customOption).toBeDefined();
|
||||
expect(customOption?.label).toBe("30s (custom)");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 3_600_000;
|
||||
|
||||
/** Minimum heartbeat interval enforced by the dashboard (5 minutes in ms) */
|
||||
export const MIN_HEARTBEAT_INTERVAL_MS = 300_000;
|
||||
|
||||
export const HEARTBEAT_INTERVAL_PRESETS = [
|
||||
{ value: 1000, label: "1s" },
|
||||
{ value: 5000, label: "5s" },
|
||||
{ value: 10000, label: "10s" },
|
||||
{ value: 30000, label: "30s" },
|
||||
{ value: 60000, label: "1m" },
|
||||
{ value: 300000, label: "5m" },
|
||||
{ value: 900000, label: "15m" },
|
||||
{ value: 1800000, label: "30m" },
|
||||
@@ -14,13 +12,18 @@ export const HEARTBEAT_INTERVAL_PRESETS = [
|
||||
{ value: 21600000, label: "6h" },
|
||||
{ value: 43200000, label: "12h" },
|
||||
{ value: 86400000, label: "24h" },
|
||||
{ value: 172800000, label: "48h" },
|
||||
{ value: 259200000, label: "72h" },
|
||||
{ value: 604800000, label: "1w" },
|
||||
] as const;
|
||||
|
||||
export function formatHeartbeatInterval(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
||||
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
||||
return `${Math.round(ms / 3_600_000)}h`;
|
||||
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
|
||||
if (ms < 604_800_000) return `${Math.round(ms / 86_400_000)}d`;
|
||||
return `${Math.round(ms / 604_800_000)}w`;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatIntervalMs(intervalMs: unknown): number {
|
||||
@@ -28,7 +31,7 @@ export function resolveHeartbeatIntervalMs(intervalMs: unknown): number {
|
||||
return DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||
}
|
||||
|
||||
return Math.max(1000, Math.round(intervalMs));
|
||||
return Math.max(MIN_HEARTBEAT_INTERVAL_MS, Math.round(intervalMs));
|
||||
}
|
||||
|
||||
export function getHeartbeatIntervalOptions(currentIntervalMs: number): Array<{ value: number; label: string }> {
|
||||
|
||||
Reference in New Issue
Block a user