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:
Fusion
2026-04-23 00:06:03 -07:00
committed by gsxdsm
parent 0bb0100072
commit 5621334d02
6 changed files with 632 additions and 50 deletions

View File

@@ -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);

View File

@@ -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 () => {

View File

@@ -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");
});

View 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)");
});
});

View File

@@ -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 }> {