fix(FN-2199): normalize heartbeat settings dirty state in agent config
- Display heartbeat interval and timeout in seconds while persisting runtimeConfig in milliseconds - Convert derived and persisted heartbeat values before dirty-state comparison so unchanged settings keep Save Settings disabled - Update heartbeat validation minimums, placeholders, and hints to align with seconds-based inputs - Expand AgentDetailView tests for pre-filled values, unchanged-state save disabling, validation, and payload conversion
This commit is contained in:
@@ -2625,10 +2625,10 @@ function deriveHeartbeatValues(runtimeConfig: AgentDetail["runtimeConfig"] | und
|
||||
const nextValues: Record<string, string> = {};
|
||||
|
||||
if (rc.heartbeatIntervalMs !== undefined && rc.heartbeatIntervalMs !== null) {
|
||||
nextValues.heartbeatIntervalMs = String(rc.heartbeatIntervalMs);
|
||||
nextValues.heartbeatIntervalMs = String(Number(rc.heartbeatIntervalMs) / 1000);
|
||||
}
|
||||
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
|
||||
nextValues.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
|
||||
nextValues.heartbeatTimeoutMs = String(Number(rc.heartbeatTimeoutMs) / 1000);
|
||||
}
|
||||
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
|
||||
nextValues.maxConcurrentRuns = String(rc.maxConcurrentRuns);
|
||||
@@ -2811,7 +2811,12 @@ function ConfigTab({
|
||||
const rc = agent.runtimeConfig ?? {};
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
|
||||
const current = heartbeatValues[key]?.trim() ?? "";
|
||||
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
||||
let persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
||||
|
||||
if ((key === "heartbeatIntervalMs" || key === "heartbeatTimeoutMs") && persisted) {
|
||||
persisted = String(Number(persisted) / 1000);
|
||||
}
|
||||
|
||||
if (current !== persisted) return true;
|
||||
}
|
||||
// Check budget config values
|
||||
@@ -2920,8 +2925,8 @@ function ConfigTab({
|
||||
|
||||
// Validate heartbeat settings
|
||||
for (const [key, config] of Object.entries({
|
||||
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1000 },
|
||||
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5000 },
|
||||
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1 },
|
||||
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5 },
|
||||
maxConcurrentRuns: { label: "Max Concurrent Runs", min: 1 },
|
||||
})) {
|
||||
const raw = heartbeatValues[key]?.trim();
|
||||
@@ -3016,7 +3021,8 @@ function ConfigTab({
|
||||
if (!raw) {
|
||||
delete newRuntimeConfig[key];
|
||||
} else {
|
||||
newRuntimeConfig[key] = Number(raw);
|
||||
const num = Number(raw);
|
||||
newRuntimeConfig[key] = key === "maxConcurrentRuns" ? num : num * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3231,13 +3237,13 @@ function ConfigTab({
|
||||
|
||||
<div className="config-fields">
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (ms)</label>
|
||||
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (s)</label>
|
||||
<input
|
||||
id="hb-heartbeatIntervalMs"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={cn("input", !!errors.heartbeatIntervalMs && "input--error")}
|
||||
placeholder={String(DEFAULT_HEARTBEAT_INTERVAL_MS)}
|
||||
placeholder={String(DEFAULT_HEARTBEAT_INTERVAL_MS / 1000)}
|
||||
value={heartbeatValues.heartbeatIntervalMs ?? ""}
|
||||
onChange={(e) => handleHeartbeatFieldChange("heartbeatIntervalMs", e.target.value)}
|
||||
/>
|
||||
@@ -3245,26 +3251,26 @@ function ConfigTab({
|
||||
<span className="config-error">{errors.heartbeatIntervalMs}</span>
|
||||
) : (
|
||||
<span className="config-hint">
|
||||
How often heartbeats are checked. Leave empty for system default ({DEFAULT_HEARTBEAT_INTERVAL_MS}ms / {DEFAULT_HEARTBEAT_INTERVAL_LABEL}).
|
||||
How often heartbeats are checked. Leave empty for system default ({DEFAULT_HEARTBEAT_INTERVAL_MS / 1000}s / {DEFAULT_HEARTBEAT_INTERVAL_LABEL}).
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-heartbeatTimeoutMs">Heartbeat Timeout (ms)</label>
|
||||
<label htmlFor="hb-heartbeatTimeoutMs">Heartbeat Timeout (s)</label>
|
||||
<input
|
||||
id="hb-heartbeatTimeoutMs"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={cn("input", !!errors.heartbeatTimeoutMs && "input--error")}
|
||||
placeholder="60000"
|
||||
placeholder="60"
|
||||
value={heartbeatValues.heartbeatTimeoutMs ?? ""}
|
||||
onChange={(e) => handleHeartbeatFieldChange("heartbeatTimeoutMs", e.target.value)}
|
||||
/>
|
||||
{errors.heartbeatTimeoutMs ? (
|
||||
<span className="config-error">{errors.heartbeatTimeoutMs}</span>
|
||||
) : (
|
||||
<span className="config-hint">Time without heartbeat before agent is considered unresponsive. Leave empty for system default (60000ms)</span>
|
||||
<span className="config-hint">Time without heartbeat before agent is considered unresponsive. Leave empty for system default (60s)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -991,8 +991,8 @@ describe("AgentDetailView", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
// Heartbeat Settings section
|
||||
expect(screen.getByLabelText("Heartbeat Interval (ms)")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Heartbeat Timeout (ms)")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Heartbeat Interval (s)")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Heartbeat Timeout (s)")).toBeInTheDocument();
|
||||
// Advanced Settings section
|
||||
expect(screen.getByLabelText("Max Retries")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Task Timeout (ms)")).toBeInTheDocument();
|
||||
@@ -1118,7 +1118,7 @@ describe("AgentDetailView", () => {
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)") as HTMLInputElement;
|
||||
expect(heartbeatInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1137,10 +1137,10 @@ describe("AgentDetailView", () => {
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
expect(heartbeatInput).toHaveAttribute("placeholder", String(DEFAULT_HEARTBEAT_INTERVAL_MS));
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
|
||||
expect(heartbeatInput).toHaveAttribute("placeholder", String(DEFAULT_HEARTBEAT_INTERVAL_MS / 1000));
|
||||
expect(
|
||||
screen.getByText(`How often heartbeats are checked. Leave empty for system default (${DEFAULT_HEARTBEAT_INTERVAL_MS}ms / 1h).`),
|
||||
screen.getByText(`How often heartbeats are checked. Leave empty for system default (${DEFAULT_HEARTBEAT_INTERVAL_MS / 1000}s / 1h).`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1168,11 +1168,11 @@ describe("AgentDetailView", () => {
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (ms)") as HTMLInputElement;
|
||||
expect(heartbeatInput.value).toBe("15000");
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)") as HTMLInputElement;
|
||||
expect(heartbeatInput.value).toBe("15");
|
||||
|
||||
const heartbeatTimeoutInput = screen.getByLabelText("Heartbeat Timeout (ms)") as HTMLInputElement;
|
||||
expect(heartbeatTimeoutInput.value).toBe("120000");
|
||||
const heartbeatTimeoutInput = screen.getByLabelText("Heartbeat Timeout (s)") as HTMLInputElement;
|
||||
expect(heartbeatTimeoutInput.value).toBe("120");
|
||||
|
||||
const retriesInput = screen.getByLabelText("Max Retries") as HTMLInputElement;
|
||||
expect(retriesInput.value).toBe("5");
|
||||
@@ -1201,6 +1201,33 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Save Settings disabled when heartbeat runtimeConfig values are pre-filled and unchanged", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
metadata: {},
|
||||
runtimeConfig: {
|
||||
heartbeatIntervalMs: 30000,
|
||||
heartbeatTimeoutMs: 60000,
|
||||
},
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("Heartbeat Interval (s)") as HTMLInputElement).value).toBe("30");
|
||||
expect((screen.getByLabelText("Heartbeat Timeout (s)") as HTMLInputElement).value).toBe("60");
|
||||
expect(screen.getByText("Save Settings")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Save Settings when a field is changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
@@ -1213,10 +1240,10 @@ describe("AgentDetailView", () => {
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "15000");
|
||||
await user.type(heartbeatInput, "15");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save Settings")).not.toBeDisabled();
|
||||
@@ -1237,7 +1264,7 @@ describe("AgentDetailView", () => {
|
||||
|
||||
// Simulate setting a non-numeric value via React's internal value setter
|
||||
// (userEvent.type on type="number" rejects non-numeric chars, so we bypass it)
|
||||
const heartbeatInput = (await screen.findByLabelText("Heartbeat Interval (ms)")) as HTMLInputElement;
|
||||
const heartbeatInput = (await screen.findByLabelText("Heartbeat Interval (s)")) as HTMLInputElement;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype, 'value'
|
||||
)?.set;
|
||||
@@ -1263,15 +1290,15 @@ describe("AgentDetailView", () => {
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatTimeoutInput = await screen.findByLabelText("Heartbeat Timeout (ms)");
|
||||
const heartbeatTimeoutInput = await screen.findByLabelText("Heartbeat Timeout (s)");
|
||||
|
||||
await user.clear(heartbeatTimeoutInput);
|
||||
await user.type(heartbeatTimeoutInput, "500");
|
||||
await user.type(heartbeatTimeoutInput, "4");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/must be at least 5,000/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/must be at least 5/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1314,10 +1341,10 @@ describe("AgentDetailView", () => {
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "15000");
|
||||
await user.type(heartbeatInput, "15");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
@@ -1351,10 +1378,10 @@ describe("AgentDetailView", () => {
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "20000");
|
||||
await user.type(heartbeatInput, "20");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
@@ -1438,7 +1465,7 @@ describe("AgentDetailView", () => {
|
||||
await navigateToSettings(user);
|
||||
|
||||
// Type "abc" directly into a text input
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "abc");
|
||||
@@ -1487,8 +1514,8 @@ describe("AgentDetailView", () => {
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
expect((heartbeatInput as HTMLInputElement).value).toBe("30000");
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
|
||||
expect((heartbeatInput as HTMLInputElement).value).toBe("30");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
|
||||
@@ -1523,10 +1550,10 @@ describe("AgentDetailView", () => {
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (ms)");
|
||||
const heartbeatInput = await screen.findByLabelText("Heartbeat Interval (s)");
|
||||
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "45000");
|
||||
await user.type(heartbeatInput, "45");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user