fix(system): preserve MQTT interval draft editing
This commit is contained in:
@@ -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="1–60 с"
|
hint="1–60 с"
|
||||||
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user