fix(FN-2198): sync agent heartbeat config UI with live runtime updates
- Refresh AgentDetailView on agent:updated SSE events while preserving local unsaved config edits - Resync heartbeat and budget form state from latest runtime config when agent data changes - Centralize heartbeat interval defaults/formatting in shared utilities and reuse them in AgentsView selectors - Add dashboard tests for default heartbeat hints, unset runtime fallback behavior, and custom interval options
This commit is contained in:
@@ -16,6 +16,7 @@ import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
|||||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||||
import { SkillMultiselect } from "./SkillMultiselect";
|
import { SkillMultiselect } from "./SkillMultiselect";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
|
import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval } from "../utils/heartbeatIntervals";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,6 +102,8 @@ const MEMORY_LAYER_DESCRIPTIONS: Record<MemoryFileInfo["layer"], string> = {
|
|||||||
dreams: "Synthesized patterns and emerging themes distilled from this agent's daily memory.",
|
dreams: "Synthesized patterns and emerging themes distilled from this agent's daily memory.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_HEARTBEAT_INTERVAL_LABEL = formatHeartbeatInterval(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string): string {
|
function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string): string {
|
||||||
if (files.some((file) => file.path === currentPath)) {
|
if (files.some((file) => file.path === currentPath)) {
|
||||||
return currentPath;
|
return currentPath;
|
||||||
@@ -121,6 +124,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
const addToastRef = useRef(addToast);
|
const addToastRef = useRef(addToast);
|
||||||
const agentRef = useRef<AgentDetail | null>(null);
|
const agentRef = useRef<AgentDetail | null>(null);
|
||||||
|
const hasConfigChangesRef = useRef(false);
|
||||||
|
|
||||||
// Track the context version to detect stale events after project/agent switches.
|
// Track the context version to detect stale events after project/agent switches.
|
||||||
// Incremented whenever agentId or projectId changes, invalidating any in-flight SSE handlers.
|
// Incremented whenever agentId or projectId changes, invalidating any in-flight SSE handlers.
|
||||||
@@ -181,6 +185,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
}
|
}
|
||||||
}, [agent?.taskId, agentId, projectId]);
|
}, [agent?.taskId, agentId, projectId]);
|
||||||
|
|
||||||
|
const handleConfigChangesState = useCallback((hasChanges: boolean) => {
|
||||||
|
hasConfigChangesRef.current = hasChanges;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadAgent();
|
void loadAgent();
|
||||||
}, [loadAgent]);
|
}, [loadAgent]);
|
||||||
@@ -213,9 +221,37 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
// Clear stale logs and streaming state immediately
|
// Clear stale logs and streaming state immediately
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
hasConfigChangesRef.current = false;
|
||||||
}
|
}
|
||||||
}, [agentId, projectId]);
|
}, [agentId, projectId]);
|
||||||
|
|
||||||
|
// Refresh this view when the current agent is updated elsewhere, unless there are unsaved edits.
|
||||||
|
useEffect(() => {
|
||||||
|
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||||
|
const contextVersionAtStart = contextVersionRef.current;
|
||||||
|
|
||||||
|
return subscribeSse(`/api/events${query}`, {
|
||||||
|
events: {
|
||||||
|
"agent:updated": (event) => {
|
||||||
|
if (contextVersionRef.current !== contextVersionAtStart) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: unknown = JSON.parse(event.data);
|
||||||
|
if (!payload || typeof payload !== "object") return;
|
||||||
|
|
||||||
|
const updatedId = (payload as { id?: unknown }).id;
|
||||||
|
if (updatedId !== agentId) return;
|
||||||
|
if (hasConfigChangesRef.current) return;
|
||||||
|
|
||||||
|
void loadAgent();
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed events
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [agentId, projectId, loadAgent]);
|
||||||
|
|
||||||
// Set up SSE for live log streaming when viewing logs tab with a task
|
// Set up SSE for live log streaming when viewing logs tab with a task
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== "logs" || !agent?.taskId) {
|
if (activeTab !== "logs" || !agent?.taskId) {
|
||||||
@@ -546,6 +582,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
onSaved={loadAgent}
|
onSaved={loadAgent}
|
||||||
|
onHasChangesChange={handleConfigChangesState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2583,16 +2620,63 @@ function PerformanceTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveHeartbeatValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
|
||||||
|
const rc = runtimeConfig ?? {};
|
||||||
|
const nextValues: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (rc.heartbeatIntervalMs !== undefined && rc.heartbeatIntervalMs !== null) {
|
||||||
|
nextValues.heartbeatIntervalMs = String(rc.heartbeatIntervalMs);
|
||||||
|
}
|
||||||
|
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
|
||||||
|
nextValues.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
|
||||||
|
}
|
||||||
|
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
|
||||||
|
nextValues.maxConcurrentRuns = String(rc.maxConcurrentRuns);
|
||||||
|
}
|
||||||
|
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
|
||||||
|
nextValues.messageResponseMode = rc.messageResponseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
|
||||||
|
const bc = (runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
|
||||||
|
const nextValues: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!bc) {
|
||||||
|
return nextValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bc.tokenBudget !== undefined && bc.tokenBudget !== null) {
|
||||||
|
nextValues.tokenBudget = String(bc.tokenBudget);
|
||||||
|
}
|
||||||
|
if (bc.usageThreshold !== undefined && bc.usageThreshold !== null) {
|
||||||
|
// Convert fraction (0-1) to percentage (0-100) for display
|
||||||
|
nextValues.usageThreshold = String(Number(bc.usageThreshold) * 100);
|
||||||
|
}
|
||||||
|
if (bc.budgetPeriod !== undefined && bc.budgetPeriod !== null) {
|
||||||
|
nextValues.budgetPeriod = String(bc.budgetPeriod);
|
||||||
|
}
|
||||||
|
if (bc.resetDay !== undefined && bc.resetDay !== null) {
|
||||||
|
nextValues.resetDay = String(bc.resetDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextValues;
|
||||||
|
}
|
||||||
|
|
||||||
function ConfigTab({
|
function ConfigTab({
|
||||||
agent,
|
agent,
|
||||||
projectId,
|
projectId,
|
||||||
addToast,
|
addToast,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
onHasChangesChange,
|
||||||
}: {
|
}: {
|
||||||
agent: AgentDetail;
|
agent: AgentDetail;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
addToast: (message: string, type?: "success" | "error") => void;
|
addToast: (message: string, type?: "success" | "error") => void;
|
||||||
onSaved: () => Promise<void>;
|
onSaved: () => Promise<void>;
|
||||||
|
onHasChangesChange?: (hasChanges: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
// Identity field state
|
// Identity field state
|
||||||
const [nameValue, setNameValue] = useState(agent.name);
|
const [nameValue, setNameValue] = useState(agent.name);
|
||||||
@@ -2614,45 +2698,14 @@ function ConfigTab({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Heartbeat config state initialised from agent.runtimeConfig
|
// Heartbeat config state initialised from agent.runtimeConfig
|
||||||
const [heartbeatValues, setHeartbeatValues] = useState<Record<string, string>>(() => {
|
const [heartbeatValues, setHeartbeatValues] = useState<Record<string, string>>(
|
||||||
const rc = agent.runtimeConfig ?? {};
|
() => deriveHeartbeatValues(agent.runtimeConfig),
|
||||||
const initial: Record<string, string> = {};
|
);
|
||||||
if (rc.heartbeatIntervalMs !== undefined && rc.heartbeatIntervalMs !== null) {
|
|
||||||
initial.heartbeatIntervalMs = String(rc.heartbeatIntervalMs);
|
|
||||||
}
|
|
||||||
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
|
|
||||||
initial.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
|
|
||||||
}
|
|
||||||
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
|
|
||||||
initial.maxConcurrentRuns = String(rc.maxConcurrentRuns);
|
|
||||||
}
|
|
||||||
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
|
|
||||||
initial.messageResponseMode = rc.messageResponseMode;
|
|
||||||
}
|
|
||||||
return initial;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Budget config state initialised from agent.runtimeConfig.budgetConfig
|
// Budget config state initialised from agent.runtimeConfig.budgetConfig
|
||||||
const [budgetValues, setBudgetValues] = useState<Record<string, string>>(() => {
|
const [budgetValues, setBudgetValues] = useState<Record<string, string>>(
|
||||||
const bc = (agent.runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
|
() => deriveBudgetValues(agent.runtimeConfig),
|
||||||
const initial: Record<string, string> = {};
|
);
|
||||||
if (bc !== undefined && bc !== null) {
|
|
||||||
if (bc.tokenBudget !== undefined && bc.tokenBudget !== null) {
|
|
||||||
initial.tokenBudget = String(bc.tokenBudget);
|
|
||||||
}
|
|
||||||
if (bc.usageThreshold !== undefined && bc.usageThreshold !== null) {
|
|
||||||
// Convert fraction (0-1) to percentage (0-100) for display
|
|
||||||
initial.usageThreshold = String(Number(bc.usageThreshold) * 100);
|
|
||||||
}
|
|
||||||
if (bc.budgetPeriod !== undefined && bc.budgetPeriod !== null) {
|
|
||||||
initial.budgetPeriod = String(bc.budgetPeriod);
|
|
||||||
}
|
|
||||||
if (bc.resetDay !== undefined && bc.resetDay !== null) {
|
|
||||||
initial.resetDay = String(bc.resetDay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return initial;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bundle config state
|
// Bundle config state
|
||||||
const [bundleMode, setBundleMode] = useState<string>(agent.bundleConfig?.mode ?? "");
|
const [bundleMode, setBundleMode] = useState<string>(agent.bundleConfig?.mode ?? "");
|
||||||
@@ -2722,6 +2775,7 @@ function ConfigTab({
|
|||||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||||
const [justSaved, setJustSaved] = useState(false);
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const previousAgentRuntimeSyncRef = useRef<{ id: string; updatedAt: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -2786,6 +2840,43 @@ function ConfigTab({
|
|||||||
return false;
|
return false;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const previousHasChangesRef = useRef<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onHasChangesChange) return;
|
||||||
|
if (previousHasChangesRef.current === hasChanges) return;
|
||||||
|
|
||||||
|
previousHasChangesRef.current = hasChanges;
|
||||||
|
onHasChangesChange(hasChanges);
|
||||||
|
}, [hasChanges, onHasChangesChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
onHasChangesChange?.(false);
|
||||||
|
};
|
||||||
|
}, [onHasChangesChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextSnapshot = { id: agent.id, updatedAt: agent.updatedAt };
|
||||||
|
const previousSnapshot = previousAgentRuntimeSyncRef.current;
|
||||||
|
const hasNewAgentData =
|
||||||
|
!previousSnapshot
|
||||||
|
|| previousSnapshot.id !== nextSnapshot.id
|
||||||
|
|| previousSnapshot.updatedAt !== nextSnapshot.updatedAt;
|
||||||
|
|
||||||
|
if (!hasNewAgentData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousAgentRuntimeSyncRef.current = nextSnapshot;
|
||||||
|
setHeartbeatValues(deriveHeartbeatValues(agent.runtimeConfig));
|
||||||
|
setBudgetValues(deriveBudgetValues(agent.runtimeConfig));
|
||||||
|
}, [agent, hasChanges]);
|
||||||
|
|
||||||
const handleFieldChange = (key: string, value: string) => {
|
const handleFieldChange = (key: string, value: string) => {
|
||||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||||
setJustSaved(false);
|
setJustSaved(false);
|
||||||
@@ -3146,14 +3237,16 @@ function ConfigTab({
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className={cn("input", !!errors.heartbeatIntervalMs && "input--error")}
|
className={cn("input", !!errors.heartbeatIntervalMs && "input--error")}
|
||||||
placeholder="30000"
|
placeholder={String(DEFAULT_HEARTBEAT_INTERVAL_MS)}
|
||||||
value={heartbeatValues.heartbeatIntervalMs ?? ""}
|
value={heartbeatValues.heartbeatIntervalMs ?? ""}
|
||||||
onChange={(e) => handleHeartbeatFieldChange("heartbeatIntervalMs", e.target.value)}
|
onChange={(e) => handleHeartbeatFieldChange("heartbeatIntervalMs", e.target.value)}
|
||||||
/>
|
/>
|
||||||
{errors.heartbeatIntervalMs ? (
|
{errors.heartbeatIntervalMs ? (
|
||||||
<span className="config-error">{errors.heartbeatIntervalMs}</span>
|
<span className="config-error">{errors.heartbeatIntervalMs}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="config-hint">How often heartbeats are checked. Leave empty for system default (30000ms)</span>
|
<span className="config-hint">
|
||||||
|
How often heartbeats are checked. Leave empty for system default ({DEFAULT_HEARTBEAT_INTERVAL_MS}ms / {DEFAULT_HEARTBEAT_INTERVAL_LABEL}).
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import { NewAgentDialog } from "./NewAgentDialog";
|
|||||||
import { AgentImportModal } from "./AgentImportModal";
|
import { AgentImportModal } from "./AgentImportModal";
|
||||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||||
|
import {
|
||||||
|
formatHeartbeatInterval,
|
||||||
|
getHeartbeatIntervalOptions,
|
||||||
|
resolveHeartbeatIntervalMs,
|
||||||
|
} from "../utils/heartbeatIntervals";
|
||||||
import { isEphemeralAgent } from "@fusion/core";
|
import { isEphemeralAgent } from "@fusion/core";
|
||||||
|
|
||||||
export interface AgentsViewProps {
|
export interface AgentsViewProps {
|
||||||
@@ -31,34 +36,6 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
|||||||
{ value: "custom", label: "Custom", icon: "✦" },
|
{ value: "custom", label: "Custom", icon: "✦" },
|
||||||
];
|
];
|
||||||
|
|
||||||
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" },
|
|
||||||
{ value: 3600000, label: "1h" },
|
|
||||||
{ value: 10800000, label: "3h" },
|
|
||||||
{ value: 21600000, label: "6h" },
|
|
||||||
{ value: 43200000, label: "12h" },
|
|
||||||
{ value: 86400000, label: "24h" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function formatInterval(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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClosestHeartbeatPreset(ms: number): number {
|
|
||||||
return HEARTBEAT_INTERVAL_PRESETS.reduce<number>((closest, preset) => {
|
|
||||||
return Math.abs(preset.value - ms) < Math.abs(closest - ms) ? preset.value : closest;
|
|
||||||
}, HEARTBEAT_INTERVAL_PRESETS[0].value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
|
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
|
||||||
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
|
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
|
||||||
@@ -454,7 +431,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
},
|
},
|
||||||
projectId,
|
projectId,
|
||||||
);
|
);
|
||||||
addToast(`Heartbeat interval updated to ${formatInterval(newIntervalMs)} for ${agent.name}`, "success");
|
addToast(`Heartbeat interval updated to ${formatHeartbeatInterval(newIntervalMs)} for ${agent.name}`, "success");
|
||||||
void loadAgents();
|
void loadAgents();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addToast(`Failed to update heartbeat interval: ${err.message}`, "error");
|
addToast(`Failed to update heartbeat interval: ${err.message}`, "error");
|
||||||
@@ -870,11 +847,8 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
displayAgents.map(agent => {
|
displayAgents.map(agent => {
|
||||||
const health = getHealthStatus(agent);
|
const health = getHealthStatus(agent);
|
||||||
const stateStyle = STATE_COLORS[agent.state];
|
const stateStyle = STATE_COLORS[agent.state];
|
||||||
const configuredIntervalMs =
|
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
|
||||||
typeof agent.runtimeConfig?.heartbeatIntervalMs === "number"
|
const heartbeatOptions = getHeartbeatIntervalOptions(configuredIntervalMs);
|
||||||
? Math.max(1000, Math.round(agent.runtimeConfig.heartbeatIntervalMs))
|
|
||||||
: 3_600_000;
|
|
||||||
const selectedIntervalMs = getClosestHeartbeatPreset(configuredIntervalMs);
|
|
||||||
const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id;
|
const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id;
|
||||||
return (
|
return (
|
||||||
<div key={agent.id} className="agent-card" style={{ borderLeftColor: stateStyle.border }}>
|
<div key={agent.id} className="agent-card" style={{ borderLeftColor: stateStyle.border }}>
|
||||||
@@ -972,15 +946,15 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="agent-heartbeat-control">
|
<div className="agent-heartbeat-control">
|
||||||
<span className="text-secondary">Heartbeat:</span>
|
<span className="text-secondary">Heartbeat:</span>
|
||||||
<span className="badge text-secondary">{formatInterval(configuredIntervalMs)}</span>
|
<span className="badge text-secondary">{formatHeartbeatInterval(configuredIntervalMs)}</span>
|
||||||
<select
|
<select
|
||||||
className="select agent-heartbeat-select"
|
className="select agent-heartbeat-select"
|
||||||
value={selectedIntervalMs}
|
value={configuredIntervalMs}
|
||||||
onChange={(e) => void handleHeartbeatIntervalChange(agent, Number(e.target.value))}
|
onChange={(e) => void handleHeartbeatIntervalChange(agent, Number(e.target.value))}
|
||||||
disabled={isUpdatingHeartbeat}
|
disabled={isUpdatingHeartbeat}
|
||||||
aria-label={`Set heartbeat interval for ${agent.name}`}
|
aria-label={`Set heartbeat interval for ${agent.name}`}
|
||||||
>
|
>
|
||||||
{HEARTBEAT_INTERVAL_PRESETS.map((preset) => (
|
{heartbeatOptions.map((preset) => (
|
||||||
<option key={preset.value} value={preset.value}>
|
<option key={preset.value} value={preset.value}>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { AgentDetailView } from "../AgentDetailView";
|
import { AgentDetailView } from "../AgentDetailView";
|
||||||
import type { AgentCapability, AgentDetail } from "../../api";
|
import type { AgentCapability, AgentDetail } from "../../api";
|
||||||
|
import { DEFAULT_HEARTBEAT_INTERVAL_MS } from "../../utils/heartbeatIntervals";
|
||||||
|
|
||||||
// Mock the API functions
|
// Mock the API functions
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
@@ -1122,6 +1123,27 @@ describe("AgentDetailView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows shared system default hint for heartbeat interval", async () => {
|
||||||
|
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentDetailView
|
||||||
|
agentId="agent-001"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateToSettings(user);
|
||||||
|
|
||||||
|
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||||
|
expect(heartbeatInput).toHaveAttribute("placeholder", String(DEFAULT_HEARTBEAT_INTERVAL_MS));
|
||||||
|
expect(
|
||||||
|
screen.getByText(`How often heartbeats are checked. Leave empty for system default (${DEFAULT_HEARTBEAT_INTERVAL_MS}ms / 1h).`),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("pre-fills heartbeat fields from agent runtimeConfig", async () => {
|
it("pre-fills heartbeat fields from agent runtimeConfig", async () => {
|
||||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
|
|||||||
@@ -181,6 +181,25 @@ describe("AgentsView", () => {
|
|||||||
expect(screen.getByDisplayValue("30s")).toBeTruthy();
|
expect(screen.getByDisplayValue("30s")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the system default heartbeat interval when runtime config is unset", async () => {
|
||||||
|
mockFetchAgents.mockResolvedValue([
|
||||||
|
{
|
||||||
|
...mockAgents[1],
|
||||||
|
runtimeConfig: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("Set heartbeat interval for Test Agent 2")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
|
||||||
|
expect(intervalSelect.value).toBe("3600000");
|
||||||
|
expect(intervalSelect.options[intervalSelect.selectedIndex]?.text).toBe("1h");
|
||||||
|
});
|
||||||
|
|
||||||
it("updates agent heartbeat interval from preset dropdown", async () => {
|
it("updates agent heartbeat interval from preset dropdown", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
@@ -202,7 +221,7 @@ describe("AgentsView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps non-preset heartbeat interval to closest preset", async () => {
|
it("shows a custom heartbeat option when configured interval is not a preset", async () => {
|
||||||
mockFetchAgents.mockResolvedValue([
|
mockFetchAgents.mockResolvedValue([
|
||||||
{
|
{
|
||||||
...mockAgents[1],
|
...mockAgents[1],
|
||||||
@@ -217,8 +236,9 @@ describe("AgentsView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
|
const intervalSelect = screen.getByLabelText("Set heartbeat interval for Test Agent 2") as HTMLSelectElement;
|
||||||
expect(intervalSelect.value).toBe("60000");
|
expect(intervalSelect.value).toBe("65000");
|
||||||
expect(screen.getAllByText("1m").length).toBeGreaterThan(0);
|
expect(intervalSelect.options[intervalSelect.selectedIndex]?.text).toBe("1m (custom)");
|
||||||
|
expect(screen.getByRole("option", { name: "1m (custom)" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows refresh button", async () => {
|
it("shows refresh button", async () => {
|
||||||
|
|||||||
46
packages/dashboard/app/utils/heartbeatIntervals.ts
Normal file
46
packages/dashboard/app/utils/heartbeatIntervals.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 3_600_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" },
|
||||||
|
{ value: 3600000, label: "1h" },
|
||||||
|
{ value: 10800000, label: "3h" },
|
||||||
|
{ value: 21600000, label: "6h" },
|
||||||
|
{ value: 43200000, label: "12h" },
|
||||||
|
{ value: 86400000, label: "24h" },
|
||||||
|
] 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHeartbeatIntervalMs(intervalMs: unknown): number {
|
||||||
|
if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs)) {
|
||||||
|
return DEFAULT_HEARTBEAT_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1000, Math.round(intervalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeartbeatIntervalOptions(currentIntervalMs: number): Array<{ value: number; label: string }> {
|
||||||
|
if (HEARTBEAT_INTERVAL_PRESETS.some((preset) => preset.value === currentIntervalMs)) {
|
||||||
|
return [...HEARTBEAT_INTERVAL_PRESETS];
|
||||||
|
}
|
||||||
|
|
||||||
|
const customOption = {
|
||||||
|
value: currentIntervalMs,
|
||||||
|
label: `${formatHeartbeatInterval(currentIntervalMs)} (custom)`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...HEARTBEAT_INTERVAL_PRESETS, customOption]
|
||||||
|
.sort((a, b) => a.value - b.value);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user