fix(system): preserve MQTT interval draft editing

This commit is contained in:
DCCONSTRUCTIONS
2026-07-28 01:56:36 +03:00
parent d2964a0ddc
commit 65a72d40a8
4 changed files with 185 additions and 19 deletions
@@ -18,6 +18,11 @@ import {
type ComputeContourDraft, type ComputeContourDraft,
type ComputeContourPlatform, type ComputeContourPlatform,
} from "../../core/system/computeContours"; } from "../../core/system/computeContours";
import {
DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
parseTelemetryPollIntervalDraft,
resolveTelemetryPollIntervalDraft,
} from "../../core/system/telemetryPollInterval";
interface ComputeContourSettingsWindowProps { interface ComputeContourSettingsWindowProps {
open: boolean; open: boolean;
@@ -51,7 +56,7 @@ function emptyDraft(): ComputeContourDraft {
ssh_port: 22, ssh_port: 22,
mqtt_host: "127.0.0.1", mqtt_host: "127.0.0.1",
mqtt_port: 1883, 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, onUpdate,
}: ComputeContourSettingsWindowProps) { }: ComputeContourSettingsWindowProps) {
const [draft, setDraft] = useState<ComputeContourDraft>(() => draftFromContour(contour)); const [draft, setDraft] = useState<ComputeContourDraft>(() => draftFromContour(contour));
const [telemetryPollIntervalDraft, setTelemetryPollIntervalDraft] = useState(
() => String(draftFromContour(contour).telemetry_poll_interval_seconds),
);
const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection"); const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection");
const [install, setInstall] = useState<ComputeContourAgentInstall | null>(null); const [install, setInstall] = useState<ComputeContourAgentInstall | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -86,7 +94,9 @@ export function ComputeContourSettingsWindow({
useEffect(() => { useEffect(() => {
if (!open) return; 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"); setActiveSection("connection");
setInstall(null); setInstall(null);
setError(null); setError(null);
@@ -107,6 +117,11 @@ export function ComputeContourSettingsWindow({
return () => controller.abort(); return () => controller.abort();
}, [contour, mode, open]); }, [contour, mode, open]);
const telemetryPollIntervalSeconds = useMemo(
() => parseTelemetryPollIntervalDraft(telemetryPollIntervalDraft),
[telemetryPollIntervalDraft],
);
const valid = useMemo(() => ( const valid = useMemo(() => (
Boolean(draft.display_name.trim()) Boolean(draft.display_name.trim())
&& Boolean(draft.expected_node_id.trim()) && Boolean(draft.expected_node_id.trim())
@@ -114,20 +129,39 @@ export function ComputeContourSettingsWindow({
&& draft.ssh_port > 0 && draft.ssh_port > 0
&& Number.isInteger(draft.mqtt_port) && Number.isInteger(draft.mqtt_port)
&& draft.mqtt_port > 0 && draft.mqtt_port > 0
&& Number.isInteger(draft.telemetry_poll_interval_seconds) && telemetryPollIntervalSeconds !== null
&& draft.telemetry_poll_interval_seconds >= 1 ), [draft, telemetryPollIntervalSeconds]);
&& draft.telemetry_poll_interval_seconds <= 60
), [draft]); 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 () => { const save = async () => {
if (!valid || busy) return; if (!valid || busy || telemetryPollIntervalSeconds === null) return;
const committedDraft = {
...draft,
telemetry_poll_interval_seconds: telemetryPollIntervalSeconds,
};
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
if (mode === "edit" && contour) { if (mode === "edit" && contour) {
await onUpdate(contour, draft); await onUpdate(contour, committedDraft);
} else { } else {
await onCreate(draft); await onCreate(committedDraft);
} }
onClose(); onClose();
} catch (reason) { } catch (reason) {
@@ -241,14 +275,26 @@ export function ComputeContourSettingsWindow({
<TextField <TextField
label="Интервал MQTT" label="Интервал MQTT"
hint="160 с" hint="160 с"
type="number" type="text"
min={1} inputMode="numeric"
max={60} autoComplete="off"
value={String(draft.telemetry_poll_interval_seconds)} aria-invalid={telemetryPollIntervalSeconds === null}
onChange={(event) => setDraft((current) => ({ value={telemetryPollIntervalDraft}
...current, onChange={(event) => setTelemetryPollIntervalDraft(event.currentTarget.value)}
telemetry_poll_interval_seconds: Number(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" ? ( {draft.telemetry_mode === "legacy-ssh" ? (
<TextField <TextField
@@ -0,0 +1,70 @@
export const DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS = 3;
export const MIN_TELEMETRY_POLL_INTERVAL_SECONDS = 1;
export const MAX_TELEMETRY_POLL_INTERVAL_SECONDS = 60;
export interface TelemetryPollIntervalDraftResolution {
accepted: boolean;
draft: string;
seconds: number;
}
function normalizeCommittedSeconds(seconds: number): number {
if (
!Number.isSafeInteger(seconds)
|| seconds < MIN_TELEMETRY_POLL_INTERVAL_SECONDS
|| seconds > 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),
),
);
}
@@ -4,6 +4,7 @@ import {
fetchWorkerTelemetry, fetchWorkerTelemetry,
type WorkerTelemetry, type WorkerTelemetry,
} from "./workerTelemetry"; } from "./workerTelemetry";
import { normalizeWorkerTelemetryPollMilliseconds } from "./telemetryPollInterval";
export const DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS = 3_000; export const DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS = 3_000;
@@ -18,6 +19,9 @@ export function useWorkerTelemetry(
pollMilliseconds = DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS, pollMilliseconds = DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS,
enabled = true, enabled = true,
): WorkerTelemetryState { ): WorkerTelemetryState {
const normalizedPollMilliseconds = normalizeWorkerTelemetryPollMilliseconds(
pollMilliseconds,
);
const [telemetry, setTelemetry] = useState<WorkerTelemetry | null>(null); const [telemetry, setTelemetry] = useState<WorkerTelemetry | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -51,9 +55,9 @@ export function useWorkerTelemetry(
useEffect(() => { useEffect(() => {
if (!enabled || loading) return; if (!enabled || loading) return;
const timer = window.setTimeout(refresh, pollMilliseconds); const timer = window.setTimeout(refresh, normalizedPollMilliseconds);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [enabled, loading, pollMilliseconds, refresh]); }, [enabled, loading, normalizedPollMilliseconds, refresh]);
return { telemetry, loading, error, refresh }; return { telemetry, loading, error, refresh };
} }
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { test } from "node:test"; import { test } from "node:test";
import ts from "typescript";
const sourceRoot = new URL("../src/", import.meta.url); const sourceRoot = new URL("../src/", import.meta.url);
@@ -19,6 +20,7 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
telemetryPolling, telemetryPolling,
contourSettings, contourSettings,
contourContract, contourContract,
pollIntervalContract,
telemetryStyles, telemetryStyles,
styles, styles,
] = await Promise.all([ ] = await Promise.all([
@@ -31,6 +33,7 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
read("core/system/useWorkerTelemetry.ts"), read("core/system/useWorkerTelemetry.ts"),
read("components/system/ComputeContourSettingsWindow.tsx"), read("components/system/ComputeContourSettingsWindow.tsx"),
read("core/system/computeContours.ts"), read("core/system/computeContours.ts"),
read("core/system/telemetryPollInterval.ts"),
read("styles/system-telemetry.css"), read("styles/system-telemetry.css"),
read("styles.css"), read("styles.css"),
]); ]);
@@ -71,6 +74,49 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
assert.doesNotMatch(networkWorkspace, /8765/); assert.doesNotMatch(networkWorkspace, /8765/);
assert.match(contourSettings, /FieldFrame label="Операционная система"/); assert.match(contourSettings, /FieldFrame label="Операционная система"/);
assert.match(contourSettings, /label="Интервал MQTT"/); 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(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/); 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);
});