From 65a72d40a8ca901878a105439304c30f2ad7b409 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 28 Jul 2026 01:56:36 +0300 Subject: [PATCH] fix(system): preserve MQTT interval draft editing --- .../system/ComputeContourSettingsWindow.tsx | 80 +++++++++++++++---- .../src/core/system/telemetryPollInterval.ts | 70 ++++++++++++++++ .../src/core/system/useWorkerTelemetry.ts | 8 +- .../test/systemTelemetry.test.mjs | 46 +++++++++++ 4 files changed, 185 insertions(+), 19 deletions(-) create mode 100644 apps/control-station/src/core/system/telemetryPollInterval.ts diff --git a/apps/control-station/src/components/system/ComputeContourSettingsWindow.tsx b/apps/control-station/src/components/system/ComputeContourSettingsWindow.tsx index 9401821..0b21315 100644 --- a/apps/control-station/src/components/system/ComputeContourSettingsWindow.tsx +++ b/apps/control-station/src/components/system/ComputeContourSettingsWindow.tsx @@ -18,6 +18,11 @@ import { type ComputeContourDraft, type ComputeContourPlatform, } from "../../core/system/computeContours"; +import { + DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS, + parseTelemetryPollIntervalDraft, + resolveTelemetryPollIntervalDraft, +} from "../../core/system/telemetryPollInterval"; interface ComputeContourSettingsWindowProps { open: boolean; @@ -51,7 +56,7 @@ function emptyDraft(): ComputeContourDraft { ssh_port: 22, mqtt_host: "127.0.0.1", mqtt_port: 1883, - telemetry_poll_interval_seconds: 3, + telemetry_poll_interval_seconds: DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS, }; } @@ -79,6 +84,9 @@ export function ComputeContourSettingsWindow({ onUpdate, }: ComputeContourSettingsWindowProps) { const [draft, setDraft] = useState(() => draftFromContour(contour)); + const [telemetryPollIntervalDraft, setTelemetryPollIntervalDraft] = useState( + () => String(draftFromContour(contour).telemetry_poll_interval_seconds), + ); const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection"); const [install, setInstall] = useState(null); const [busy, setBusy] = useState(false); @@ -86,7 +94,9 @@ export function ComputeContourSettingsWindow({ useEffect(() => { if (!open) return; - setDraft(draftFromContour(mode === "edit" ? contour : null)); + const nextDraft = draftFromContour(mode === "edit" ? contour : null); + setDraft(nextDraft); + setTelemetryPollIntervalDraft(String(nextDraft.telemetry_poll_interval_seconds)); setActiveSection("connection"); setInstall(null); setError(null); @@ -107,6 +117,11 @@ export function ComputeContourSettingsWindow({ return () => controller.abort(); }, [contour, mode, open]); + const telemetryPollIntervalSeconds = useMemo( + () => parseTelemetryPollIntervalDraft(telemetryPollIntervalDraft), + [telemetryPollIntervalDraft], + ); + const valid = useMemo(() => ( Boolean(draft.display_name.trim()) && Boolean(draft.expected_node_id.trim()) @@ -114,20 +129,39 @@ export function ComputeContourSettingsWindow({ && draft.ssh_port > 0 && Number.isInteger(draft.mqtt_port) && draft.mqtt_port > 0 - && Number.isInteger(draft.telemetry_poll_interval_seconds) - && draft.telemetry_poll_interval_seconds >= 1 - && draft.telemetry_poll_interval_seconds <= 60 - ), [draft]); + && telemetryPollIntervalSeconds !== null + ), [draft, telemetryPollIntervalSeconds]); + + const commitTelemetryPollInterval = () => { + const resolution = resolveTelemetryPollIntervalDraft( + telemetryPollIntervalDraft, + draft.telemetry_poll_interval_seconds, + ); + setTelemetryPollIntervalDraft(resolution.draft); + if (!resolution.accepted) return; + setDraft((current) => ( + current.telemetry_poll_interval_seconds === resolution.seconds + ? current + : { + ...current, + telemetry_poll_interval_seconds: resolution.seconds, + } + )); + }; const save = async () => { - if (!valid || busy) return; + if (!valid || busy || telemetryPollIntervalSeconds === null) return; + const committedDraft = { + ...draft, + telemetry_poll_interval_seconds: telemetryPollIntervalSeconds, + }; setBusy(true); setError(null); try { if (mode === "edit" && contour) { - await onUpdate(contour, draft); + await onUpdate(contour, committedDraft); } else { - await onCreate(draft); + await onCreate(committedDraft); } onClose(); } catch (reason) { @@ -241,14 +275,26 @@ export function ComputeContourSettingsWindow({ setDraft((current) => ({ - ...current, - telemetry_poll_interval_seconds: Number(event.currentTarget.value), - }))} + type="text" + inputMode="numeric" + autoComplete="off" + aria-invalid={telemetryPollIntervalSeconds === null} + value={telemetryPollIntervalDraft} + onChange={(event) => setTelemetryPollIntervalDraft(event.currentTarget.value)} + onBlur={commitTelemetryPollInterval} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + commitTelemetryPollInterval(); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setTelemetryPollIntervalDraft( + String(draft.telemetry_poll_interval_seconds), + ); + } + }} /> {draft.telemetry_mode === "legacy-ssh" ? ( MAX_TELEMETRY_POLL_INTERVAL_SECONDS + ) { + return DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS; + } + return seconds; +} + +export function parseTelemetryPollIntervalDraft(draft: string): number | null { + const candidate = draft.trim(); + if (!/^[0-9]+$/.test(candidate)) return null; + + const seconds = Number(candidate); + if ( + !Number.isSafeInteger(seconds) + || seconds < MIN_TELEMETRY_POLL_INTERVAL_SECONDS + || seconds > MAX_TELEMETRY_POLL_INTERVAL_SECONDS + ) { + return null; + } + return seconds; +} + +export function resolveTelemetryPollIntervalDraft( + draft: string, + committedSeconds: number, +): TelemetryPollIntervalDraftResolution { + const seconds = parseTelemetryPollIntervalDraft(draft); + if (seconds === null) { + const fallbackSeconds = normalizeCommittedSeconds(committedSeconds); + return { + accepted: false, + draft: String(fallbackSeconds), + seconds: fallbackSeconds, + }; + } + return { + accepted: true, + draft: String(seconds), + seconds, + }; +} + +export function normalizeWorkerTelemetryPollMilliseconds( + pollMilliseconds: number, +): number { + if (!Number.isFinite(pollMilliseconds)) { + return DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS * 1_000; + } + return Math.min( + MAX_TELEMETRY_POLL_INTERVAL_SECONDS * 1_000, + Math.max( + MIN_TELEMETRY_POLL_INTERVAL_SECONDS * 1_000, + Math.round(pollMilliseconds), + ), + ); +} diff --git a/apps/control-station/src/core/system/useWorkerTelemetry.ts b/apps/control-station/src/core/system/useWorkerTelemetry.ts index 5a8e85b..d88acd1 100644 --- a/apps/control-station/src/core/system/useWorkerTelemetry.ts +++ b/apps/control-station/src/core/system/useWorkerTelemetry.ts @@ -4,6 +4,7 @@ import { fetchWorkerTelemetry, type WorkerTelemetry, } from "./workerTelemetry"; +import { normalizeWorkerTelemetryPollMilliseconds } from "./telemetryPollInterval"; export const DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS = 3_000; @@ -18,6 +19,9 @@ export function useWorkerTelemetry( pollMilliseconds = DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS, enabled = true, ): WorkerTelemetryState { + const normalizedPollMilliseconds = normalizeWorkerTelemetryPollMilliseconds( + pollMilliseconds, + ); const [telemetry, setTelemetry] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -51,9 +55,9 @@ export function useWorkerTelemetry( useEffect(() => { if (!enabled || loading) return; - const timer = window.setTimeout(refresh, pollMilliseconds); + const timer = window.setTimeout(refresh, normalizedPollMilliseconds); return () => window.clearTimeout(timer); - }, [enabled, loading, pollMilliseconds, refresh]); + }, [enabled, loading, normalizedPollMilliseconds, refresh]); return { telemetry, loading, error, refresh }; } diff --git a/apps/control-station/test/systemTelemetry.test.mjs b/apps/control-station/test/systemTelemetry.test.mjs index 31a5db9..78ac357 100644 --- a/apps/control-station/test/systemTelemetry.test.mjs +++ b/apps/control-station/test/systemTelemetry.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { test } from "node:test"; +import ts from "typescript"; const sourceRoot = new URL("../src/", import.meta.url); @@ -19,6 +20,7 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () => telemetryPolling, contourSettings, contourContract, + pollIntervalContract, telemetryStyles, styles, ] = await Promise.all([ @@ -31,6 +33,7 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () => read("core/system/useWorkerTelemetry.ts"), read("components/system/ComputeContourSettingsWindow.tsx"), read("core/system/computeContours.ts"), + read("core/system/telemetryPollInterval.ts"), read("styles/system-telemetry.css"), read("styles.css"), ]); @@ -71,6 +74,49 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () => assert.doesNotMatch(networkWorkspace, /8765/); assert.match(contourSettings, /FieldFrame label="Операционная система"/); assert.match(contourSettings, /label="Интервал MQTT"/); + assert.match(contourSettings, /value=\{telemetryPollIntervalDraft\}/); + assert.match(contourSettings, /onBlur=\{commitTelemetryPollInterval\}/); + assert.match(contourSettings, /event\.key === "Escape"/); + assert.doesNotMatch( + contourSettings, + /telemetry_poll_interval_seconds:\s*Number\(event\.currentTarget\.value\)/, + ); assert.match(contourContract, /telemetry_poll_interval_seconds: number/); + assert.match(pollIntervalContract, /MIN_TELEMETRY_POLL_INTERVAL_SECONDS\s*=\s*1/); + assert.match(telemetryPolling, /normalizeWorkerTelemetryPollMilliseconds/); assert.match(styles, /system-telemetry\.css/); }); + +test("MQTT polling interval keeps an unrestricted draft until explicit commit", async () => { + const source = await read("core/system/telemetryPollInterval.ts"); + const javascript = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const contract = await import( + `data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}` + ); + + assert.equal(contract.parseTelemetryPollIntervalDraft(""), null); + assert.equal(contract.parseTelemetryPollIntervalDraft("0"), null); + assert.equal(contract.parseTelemetryPollIntervalDraft("0,1"), null); + assert.equal(contract.parseTelemetryPollIntervalDraft("0.1"), null); + assert.equal(contract.parseTelemetryPollIntervalDraft("1"), 1); + assert.equal(contract.parseTelemetryPollIntervalDraft("60"), 60); + assert.equal(contract.parseTelemetryPollIntervalDraft("61"), null); + + assert.deepEqual(contract.resolveTelemetryPollIntervalDraft("", 3), { + accepted: false, + draft: "3", + seconds: 3, + }); + assert.deepEqual(contract.resolveTelemetryPollIntervalDraft("01", 3), { + accepted: true, + draft: "1", + seconds: 1, + }); + assert.equal(contract.normalizeWorkerTelemetryPollMilliseconds(100), 1_000); + assert.equal(contract.normalizeWorkerTelemetryPollMilliseconds(3_000), 3_000); +});