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 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<ComputeContourDraft>(() => draftFromContour(contour));
const [telemetryPollIntervalDraft, setTelemetryPollIntervalDraft] = useState(
() => String(draftFromContour(contour).telemetry_poll_interval_seconds),
);
const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection");
const [install, setInstall] = useState<ComputeContourAgentInstall | null>(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({
<TextField
label="Интервал MQTT"
hint="160 с"
type="number"
min={1}
max={60}
value={String(draft.telemetry_poll_interval_seconds)}
onChange={(event) => 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" ? (
<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,
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<WorkerTelemetry | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}
@@ -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);
});