feat(system): configure telemetry polling per contour

This commit is contained in:
DCCONSTRUCTIONS
2026-07-28 01:24:05 +03:00
parent 4a65b52f6d
commit 2ffbb51a7e
8 changed files with 68 additions and 23 deletions
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { import {
Button, Button,
FeatureSettingsWindow, FeatureSettingsWindow,
FieldFrame,
Icon, Icon,
Select, Select,
SettingsCard, SettingsCard,
@@ -50,6 +51,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,
}; };
} }
@@ -64,6 +66,7 @@ function draftFromContour(contour: ComputeContour | null): ComputeContourDraft {
ssh_port: contour.ssh_port, ssh_port: contour.ssh_port,
mqtt_host: contour.mqtt_host, mqtt_host: contour.mqtt_host,
mqtt_port: contour.mqtt_port, mqtt_port: contour.mqtt_port,
telemetry_poll_interval_seconds: contour.telemetry_poll_interval_seconds,
}; };
} }
@@ -111,6 +114,9 @@ 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)
&& draft.telemetry_poll_interval_seconds >= 1
&& draft.telemetry_poll_interval_seconds <= 60
), [draft]); ), [draft]);
const save = async () => { const save = async () => {
@@ -194,14 +200,16 @@ export function ComputeContourSettingsWindow({
expected_node_id: event.currentTarget.value, expected_node_id: event.currentTarget.value,
}))} }))}
/> />
<Select <FieldFrame label="Операционная система">
label="Платформа" <Select
value={draft.platform} label="Операционная система"
options={platformOptions} value={draft.platform}
variant="split" options={platformOptions}
menuWidth="anchor" variant="split"
onChange={(platform) => setDraft((current) => ({ ...current, platform }))} menuWidth="anchor"
/> onChange={(platform) => setDraft((current) => ({ ...current, platform }))}
/>
</FieldFrame>
<TextField <TextField
label="Адрес узла" label="Адрес узла"
hint="IP или hostname в текущей LAN" hint="IP или hostname в текущей LAN"
@@ -230,6 +238,18 @@ export function ComputeContourSettingsWindow({
mqtt_port: Number(event.currentTarget.value), mqtt_port: Number(event.currentTarget.value),
}))} }))}
/> />
<TextField
label="Интервал MQTT"
hint="1–60 с"
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),
}))}
/>
{draft.telemetry_mode === "legacy-ssh" ? ( {draft.telemetry_mode === "legacy-ssh" ? (
<TextField <TextField
label="SSH port" label="SSH port"
@@ -13,6 +13,7 @@ export interface ComputeContour {
ssh_port: number; ssh_port: number;
mqtt_host: string; mqtt_host: string;
mqtt_port: number; mqtt_port: number;
telemetry_poll_interval_seconds: number;
revision: number; revision: number;
updated_at_utc: string | null; updated_at_utc: string | null;
} }
@@ -31,6 +32,7 @@ export interface ComputeContourDraft {
ssh_port: number; ssh_port: number;
mqtt_host: string; mqtt_host: string;
mqtt_port: number; mqtt_port: number;
telemetry_poll_interval_seconds: number;
} }
export interface ComputeContourAgentInstall { export interface ComputeContourAgentInstall {
@@ -96,6 +98,7 @@ export async function createComputeContour(
ssh_port: draft.ssh_port, ssh_port: draft.ssh_port,
mqtt_host: draft.mqtt_host, mqtt_host: draft.mqtt_host,
mqtt_port: draft.mqtt_port, mqtt_port: draft.mqtt_port,
telemetry_poll_interval_seconds: draft.telemetry_poll_interval_seconds,
}), }),
signal, signal,
}); });
@@ -5,7 +5,7 @@ import {
type WorkerTelemetry, type WorkerTelemetry,
} from "./workerTelemetry"; } from "./workerTelemetry";
export const WORKER_TELEMETRY_POLL_MILLISECONDS = 3_000; export const DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS = 3_000;
export interface WorkerTelemetryState { export interface WorkerTelemetryState {
telemetry: WorkerTelemetry | null; telemetry: WorkerTelemetry | null;
@@ -15,7 +15,7 @@ export interface WorkerTelemetryState {
} }
export function useWorkerTelemetry( export function useWorkerTelemetry(
pollMilliseconds = WORKER_TELEMETRY_POLL_MILLISECONDS, pollMilliseconds = DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS,
enabled = true, enabled = true,
): WorkerTelemetryState { ): WorkerTelemetryState {
const [telemetry, setTelemetry] = useState<WorkerTelemetry | null>(null); const [telemetry, setTelemetry] = useState<WorkerTelemetry | null>(null);
@@ -16,7 +16,6 @@ import {
import { useComputeContours } from "../../core/system/ComputeContourContext"; import { useComputeContours } from "../../core/system/ComputeContourContext";
import { import {
useWorkerTelemetry, useWorkerTelemetry,
WORKER_TELEMETRY_POLL_MILLISECONDS,
} from "../../core/system/useWorkerTelemetry"; } from "../../core/system/useWorkerTelemetry";
function pipelineStateLabel(state: string): string { function pipelineStateLabel(state: string): string {
@@ -30,8 +29,8 @@ export function ComputeModulesWorkspace() {
const legacyDiagnostic = selectedContour?.contour_id === "worker-006" const legacyDiagnostic = selectedContour?.contour_id === "worker-006"
&& selectedContour.telemetry_mode === "legacy-ssh"; && selectedContour.telemetry_mode === "legacy-ssh";
const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006"; const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006";
const { telemetry, loading, error, refresh } = useWorkerTelemetry( const { telemetry, error, refresh } = useWorkerTelemetry(
WORKER_TELEMETRY_POLL_MILLISECONDS, (selectedContour?.telemetry_poll_interval_seconds ?? 3) * 1_000,
supportsLiveTelemetry, supportsLiveTelemetry,
); );
const node = telemetry?.node ?? null; const node = telemetry?.node ?? null;
@@ -67,11 +66,11 @@ export function ComputeModulesWorkspace() {
<Button <Button
size="compact" size="compact"
variant="secondary" variant="secondary"
disabled={loading} disabled={!supportsLiveTelemetry}
onClick={refresh} onClick={refresh}
icon={<Icon name="refresh" size={14} />} icon={<Icon name="refresh" size={14} />}
> >
{loading ? "Читаем" : "Обновить"} Обновить
</Button> </Button>
</div> </div>
</section> </section>
@@ -15,7 +15,6 @@ import {
import { useComputeContours } from "../../core/system/ComputeContourContext"; import { useComputeContours } from "../../core/system/ComputeContourContext";
import { import {
useWorkerTelemetry, useWorkerTelemetry,
WORKER_TELEMETRY_POLL_MILLISECONDS,
} from "../../core/system/useWorkerTelemetry"; } from "../../core/system/useWorkerTelemetry";
export function NetworkWorkspace() { export function NetworkWorkspace() {
@@ -23,8 +22,8 @@ export function NetworkWorkspace() {
const legacyDiagnostic = selectedContour?.contour_id === "worker-006" const legacyDiagnostic = selectedContour?.contour_id === "worker-006"
&& selectedContour.telemetry_mode === "legacy-ssh"; && selectedContour.telemetry_mode === "legacy-ssh";
const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006"; const supportsLiveTelemetry = selectedContour?.contour_id === "worker-006";
const { telemetry, loading, error, refresh } = useWorkerTelemetry( const { telemetry, error, refresh } = useWorkerTelemetry(
WORKER_TELEMETRY_POLL_MILLISECONDS, (selectedContour?.telemetry_poll_interval_seconds ?? 3) * 1_000,
supportsLiveTelemetry, supportsLiveTelemetry,
); );
const aggregate = telemetry?.network.aggregate ?? null; const aggregate = telemetry?.network.aggregate ?? null;
@@ -54,11 +53,11 @@ export function NetworkWorkspace() {
<Button <Button
size="compact" size="compact"
variant="secondary" variant="secondary"
disabled={loading || !supportsLiveTelemetry} disabled={!supportsLiveTelemetry}
onClick={refresh} onClick={refresh}
icon={<Icon name="refresh" size={14} />} icon={<Icon name="refresh" size={14} />}
> >
{loading ? "Читаем" : "Обновить"} Обновить
</Button> </Button>
</div> </div>
</section> </section>
@@ -17,6 +17,8 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
pipelineStages, pipelineStages,
networkWorkspace, networkWorkspace,
telemetryPolling, telemetryPolling,
contourSettings,
contourContract,
telemetryStyles, telemetryStyles,
styles, styles,
] = await Promise.all([ ] = await Promise.all([
@@ -27,6 +29,8 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
read("components/system/WorkerPipelineStages.tsx"), read("components/system/WorkerPipelineStages.tsx"),
read("workspaces/system/NetworkWorkspace.tsx"), read("workspaces/system/NetworkWorkspace.tsx"),
read("core/system/useWorkerTelemetry.ts"), read("core/system/useWorkerTelemetry.ts"),
read("components/system/ComputeContourSettingsWindow.tsx"),
read("core/system/computeContours.ts"),
read("styles/system-telemetry.css"), read("styles/system-telemetry.css"),
read("styles.css"), read("styles.css"),
]); ]);
@@ -41,20 +45,27 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
assert.match(core, /share_percent/); assert.match(core, /share_percent/);
assert.doesNotMatch(core, /@nodedc\/ui-react/); assert.doesNotMatch(core, /@nodedc\/ui-react/);
assert.match(computeWorkspace, /useWorkerTelemetry/); assert.match(computeWorkspace, /useWorkerTelemetry/);
assert.match(computeWorkspace, /WORKER_TELEMETRY_POLL_MILLISECONDS/); assert.match(computeWorkspace, /telemetry_poll_interval_seconds/);
assert.match(computeWorkspace, />\s*Обновить\s*</);
assert.doesNotMatch(computeWorkspace, /loading\s*\?\s*"Читаем"/);
assert.match(computeWorkspace, /WorkerPipelineStages/); assert.match(computeWorkspace, /WorkerPipelineStages/);
assert.match(pipelineStages, /измеренного времени/); assert.match(pipelineStages, /измеренного времени/);
assert.doesNotMatch(pipelineStages, /CPU|GPU|hardware/); assert.doesNotMatch(pipelineStages, /CPU|GPU|hardware/);
assert.match( assert.match(
telemetryPolling, telemetryPolling,
/WORKER_TELEMETRY_POLL_MILLISECONDS\s*=\s*3_000/, /DEFAULT_WORKER_TELEMETRY_POLL_MILLISECONDS\s*=\s*3_000/,
); );
assert.match( assert.match(
telemetryStyles, telemetryStyles,
/\.worker-stage-list\s*\{[^}]*grid-template-columns:\s*repeat\(3,/s, /\.worker-stage-list\s*\{[^}]*grid-template-columns:\s*repeat\(3,/s,
); );
assert.match(networkWorkspace, /127\.0\.0\.1:8000/); assert.match(networkWorkspace, /127\.0\.0\.1:8000/);
assert.match(networkWorkspace, /WORKER_TELEMETRY_POLL_MILLISECONDS/); assert.match(networkWorkspace, /telemetry_poll_interval_seconds/);
assert.match(networkWorkspace, />\s*Обновить\s*</);
assert.doesNotMatch(networkWorkspace, /loading\s*\?\s*"Читаем"/);
assert.doesNotMatch(networkWorkspace, /8765/); assert.doesNotMatch(networkWorkspace, /8765/);
assert.match(contourSettings, /FieldFrame label="Операционная система"/);
assert.match(contourSettings, /label="Интервал MQTT"/);
assert.match(contourContract, /telemetry_poll_interval_seconds: number/);
assert.match(styles, /system-telemetry\.css/); assert.match(styles, /system-telemetry\.css/);
}); });
+9
View File
@@ -46,6 +46,7 @@ class ComputeContour(StrictModel):
ssh_port: int = Field(default=22, ge=1, le=65535) ssh_port: int = Field(default=22, ge=1, le=65535)
mqtt_host: str = Field(default="127.0.0.1", min_length=1, max_length=253) mqtt_host: str = Field(default="127.0.0.1", min_length=1, max_length=253)
mqtt_port: int = Field(default=1883, ge=1, le=65535) mqtt_port: int = Field(default=1883, ge=1, le=65535)
telemetry_poll_interval_seconds: int = Field(default=3, ge=1, le=60)
revision: int = Field(default=0, ge=0) revision: int = Field(default=0, ge=0)
updated_at_utc: str | None = None updated_at_utc: str | None = None
@@ -90,6 +91,7 @@ class ComputeContourCreate(StrictModel):
ssh_port: int = Field(default=22, ge=1, le=65535) ssh_port: int = Field(default=22, ge=1, le=65535)
mqtt_host: str = Field(default="127.0.0.1", min_length=1, max_length=253) mqtt_host: str = Field(default="127.0.0.1", min_length=1, max_length=253)
mqtt_port: int = Field(default=1883, ge=1, le=65535) mqtt_port: int = Field(default=1883, ge=1, le=65535)
telemetry_poll_interval_seconds: int = Field(default=3, ge=1, le=60)
@field_validator("display_name") @field_validator("display_name")
@classmethod @classmethod
@@ -124,6 +126,7 @@ def default_compute_contour() -> ComputeContour:
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,
) )
@@ -152,6 +155,9 @@ class ComputeContourStore:
ssh_port=request.ssh_port, ssh_port=request.ssh_port,
mqtt_host=request.mqtt_host, mqtt_host=request.mqtt_host,
mqtt_port=request.mqtt_port, mqtt_port=request.mqtt_port,
telemetry_poll_interval_seconds=(
request.telemetry_poll_interval_seconds
),
revision=0, revision=0,
updated_at_utc=_utc_now(), updated_at_utc=_utc_now(),
) )
@@ -177,6 +183,9 @@ class ComputeContourStore:
"ssh_port": request.ssh_port, "ssh_port": request.ssh_port,
"mqtt_host": request.mqtt_host, "mqtt_host": request.mqtt_host,
"mqtt_port": request.mqtt_port, "mqtt_port": request.mqtt_port,
"telemetry_poll_interval_seconds": (
request.telemetry_poll_interval_seconds
),
"revision": current.revision + 1, "revision": current.revision + 1,
"updated_at_utc": _utc_now(), "updated_at_utc": _utc_now(),
} }
+4
View File
@@ -39,6 +39,7 @@ def test_contour_store_migrates_worker_006_as_first_configuration(
assert len(contours) == 1 assert len(contours) == 1
assert contours[0].contour_id == "worker-006" assert contours[0].contour_id == "worker-006"
assert contours[0].telemetry_mode == "agent-mqtt" assert contours[0].telemetry_mode == "agent-mqtt"
assert contours[0].telemetry_poll_interval_seconds == 3
assert not store.path.exists() assert not store.path.exists()
@@ -65,10 +66,12 @@ def test_contour_store_creates_and_updates_private_catalog(tmp_path: Path) -> No
ssh_port=22, ssh_port=22,
mqtt_host="192.0.2.5", mqtt_host="192.0.2.5",
mqtt_port=1883, mqtt_port=1883,
telemetry_poll_interval_seconds=1,
), ),
) )
assert updated.display_name == "Field Worker 01" assert updated.display_name == "Field Worker 01"
assert updated.telemetry_poll_interval_seconds == 1
assert updated.revision == 1 assert updated.revision == 1
assert stat.S_IMODE(store.path.stat().st_mode) == 0o600 assert stat.S_IMODE(store.path.stat().st_mode) == 0o600
assert len(store.list_contours()) == 2 assert len(store.list_contours()) == 2
@@ -85,6 +88,7 @@ def test_contour_store_creates_and_updates_private_catalog(tmp_path: Path) -> No
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,
), ),
) )