From 67e12a47a4bdf55452b98aff69911338c3229640 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 27 Jul 2026 21:06:51 +0300 Subject: [PATCH] feat(system): add Worker 006 telemetry and network profile --- apps/control-station/src/App.tsx | 4 + .../src/components/system/TelemetrySeries.tsx | 47 + .../components/system/WorkerRuntimeCard.tsx | 50 ++ .../src/components/system/systemFormat.ts | 67 ++ .../src/core/system/useWorkerTelemetry.ts | 47 + .../src/core/system/workerTelemetry.ts | 249 ++++++ apps/control-station/src/productModel.ts | 14 +- apps/control-station/src/styles.css | 1 + .../src/styles/system-telemetry.css | 544 ++++++++++++ .../src/workspaces/Workspaces.tsx | 6 + .../system/ComputeModulesWorkspace.tsx | 226 +++++ .../workspaces/system/NetworkWorkspace.tsx | 311 +++++++ .../test/systemTelemetry.test.mjs | 40 + .../worker/run_e15_shadow_inference.py | 48 +- src/k1link/web/app.py | 6 + src/k1link/web/system_telemetry_api.py | 834 ++++++++++++++++++ tests/test_system_telemetry_api.py | 237 +++++ 17 files changed, 2723 insertions(+), 8 deletions(-) create mode 100644 apps/control-station/src/components/system/TelemetrySeries.tsx create mode 100644 apps/control-station/src/components/system/WorkerRuntimeCard.tsx create mode 100644 apps/control-station/src/components/system/systemFormat.ts create mode 100644 apps/control-station/src/core/system/useWorkerTelemetry.ts create mode 100644 apps/control-station/src/core/system/workerTelemetry.ts create mode 100644 apps/control-station/src/styles/system-telemetry.css create mode 100644 apps/control-station/src/workspaces/system/ComputeModulesWorkspace.tsx create mode 100644 apps/control-station/src/workspaces/system/NetworkWorkspace.tsx create mode 100644 apps/control-station/test/systemTelemetry.test.mjs create mode 100644 src/k1link/web/system_telemetry_api.py create mode 100644 tests/test_system_telemetry_api.py diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 04da62a..0cfc3d6 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -795,6 +795,10 @@ export default function App() { Offline evaluation ) : activeDefinition.kind === "lab-archive" ? ( null + ) : activeDefinition.kind === "compute-modules" ? ( + Живая телеметрия + ) : activeDefinition.kind === "network-monitor" ? ( + Живая сеть ) : ( Интерфейс готов ) diff --git a/apps/control-station/src/components/system/TelemetrySeries.tsx b/apps/control-station/src/components/system/TelemetrySeries.tsx new file mode 100644 index 0000000..fbbed41 --- /dev/null +++ b/apps/control-station/src/components/system/TelemetrySeries.tsx @@ -0,0 +1,47 @@ +interface TelemetrySeriesProps { + label: string; + values: Array; + value: string; + ceiling?: number; +} + +function linePoints(values: Array, ceiling?: number): string { + const finite = values.filter((value): value is number => + value !== null && Number.isFinite(value), + ); + if (finite.length === 0) return ""; + const maximum = Math.max(ceiling ?? 0, ...finite, 1); + const denominator = Math.max(1, values.length - 1); + return values.map((value, index) => { + const x = index / denominator * 100; + const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value)); + const y = 36 - normalized / maximum * 34; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }).join(" "); +} + +export function TelemetrySeries({ + label, + values, + value, + ceiling, +}: TelemetrySeriesProps) { + const points = linePoints(values, ceiling); + return ( +
+
+ {label} + {value} +
+ + + {points ? : null} + +
+ ); +} diff --git a/apps/control-station/src/components/system/WorkerRuntimeCard.tsx b/apps/control-station/src/components/system/WorkerRuntimeCard.tsx new file mode 100644 index 0000000..7eb0bc4 --- /dev/null +++ b/apps/control-station/src/components/system/WorkerRuntimeCard.tsx @@ -0,0 +1,50 @@ +import { GlassSurface, StatusBadge } from "@nodedc/ui-react"; + +import type { WorkerRuntime } from "../../core/system/workerTelemetry"; +import { + formatOptionalPercent, + runtimeStateLabel, + runtimeTone, +} from "./systemFormat"; + +export function WorkerRuntimeCard({ runtime }: { runtime: WorkerRuntime }) { + return ( + +
+
+ + {runtime.external ? "ВНЕШНЯЯ НАГРУЗКА" : "MISSION CORE RUNTIME"} + +

{runtime.role}

+ {runtime.name} +
+ + {runtimeStateLabel(runtime)} + +
+
+
+
CPU
+
{formatOptionalPercent(runtime.cpu_percent)}
+
+
+
Память
+
{runtime.memory_usage ?? "—"}
+
+
+
Сеть I/O
+
{runtime.network_io ?? "—"}
+
+
+
Процессы
+
{runtime.pids ?? "—"}
+
+
+
+ ); +} diff --git a/apps/control-station/src/components/system/systemFormat.ts b/apps/control-station/src/components/system/systemFormat.ts new file mode 100644 index 0000000..056774c --- /dev/null +++ b/apps/control-station/src/components/system/systemFormat.ts @@ -0,0 +1,67 @@ +import type { StatusTone } from "@nodedc/ui-react"; + +import type { WorkerRuntime } from "../../core/system/workerTelemetry"; + +export function formatOptionalPercent(value: number | null | undefined): string { + return typeof value === "number" && Number.isFinite(value) + ? `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1 }).format(value)}%` + : "—"; +} + +export function formatBytes(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + const units = ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ"]; + let current = Math.max(0, value); + let unit = 0; + while (current >= 1024 && unit < units.length - 1) { + current /= 1024; + unit += 1; + } + return `${new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: current >= 100 ? 0 : 1, + }).format(current)} ${units[unit]}`; +} + +export function formatRate(value: number | null | undefined): string { + const formatted = formatBytes(value); + return formatted === "—" ? formatted : `${formatted}/с`; +} + +export function formatBitRate(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + const units = ["бит/с", "Кбит/с", "Мбит/с", "Гбит/с"]; + let current = Math.max(0, value); + let unit = 0; + while (current >= 1000 && unit < units.length - 1) { + current /= 1000; + unit += 1; + } + return `${new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: current >= 100 ? 0 : 1, + }).format(current)} ${units[unit]}`; +} + +export function formatDuration(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + const hours = Math.floor(value / 3600); + if (hours < 24) return `${hours} ч`; + return `${Math.floor(hours / 24)} д ${hours % 24} ч`; +} + +export function formatLatency(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return `${Math.round(value)} мс`; +} + +export function runtimeTone(runtime: WorkerRuntime): StatusTone { + if (runtime.state !== "running") return "danger"; + if (runtime.health && runtime.health !== "healthy") return "warning"; + return runtime.external ? "neutral" : "success"; +} + +export function runtimeStateLabel(runtime: WorkerRuntime): string { + if (runtime.state !== "running") return "Недоступен"; + if (runtime.health === "healthy") return "Работает"; + if (runtime.health) return runtime.health; + return "Запущен"; +} diff --git a/apps/control-station/src/core/system/useWorkerTelemetry.ts b/apps/control-station/src/core/system/useWorkerTelemetry.ts new file mode 100644 index 0000000..794fc34 --- /dev/null +++ b/apps/control-station/src/core/system/useWorkerTelemetry.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from "react"; + +import { + fetchWorkerTelemetry, + type WorkerTelemetry, +} from "./workerTelemetry"; + +export interface WorkerTelemetryState { + telemetry: WorkerTelemetry | null; + loading: boolean; + error: string | null; + refresh: () => void; +} + +export function useWorkerTelemetry(pollMilliseconds = 10_000): WorkerTelemetryState { + const [telemetry, setTelemetry] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [generation, setGeneration] = useState(0); + const refresh = useCallback(() => setGeneration((value) => value + 1), []); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + void fetchWorkerTelemetry(controller.signal) + .then((document) => { + if (controller.signal.aborted) return; + setTelemetry(document); + setError(null); + }) + .catch((reason: unknown) => { + if (controller.signal.aborted) return; + setError(reason instanceof Error ? reason.message : "Worker 006 не ответил."); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [generation]); + + useEffect(() => { + const timer = window.setInterval(refresh, pollMilliseconds); + return () => window.clearInterval(timer); + }, [pollMilliseconds, refresh]); + + return { telemetry, loading, error, refresh }; +} diff --git a/apps/control-station/src/core/system/workerTelemetry.ts b/apps/control-station/src/core/system/workerTelemetry.ts new file mode 100644 index 0000000..326663b --- /dev/null +++ b/apps/control-station/src/core/system/workerTelemetry.ts @@ -0,0 +1,249 @@ +export interface WorkerConnectionProfile { + schema_version: "missioncore.worker-connection-profile/v1"; + profile_id: string; + display_name: string; + expected_node_id: string; + ssh_host_alias: string; + address: string; + port: number; + revision: number; + updated_at_utc: string | null; +} + +export interface WorkerProbe { + schema_version: "missioncore.worker-probe/v1"; + reachable: boolean; + identity_matches: boolean; + node_id: string | null; + latency_ms: number | null; + observed_at_utc: string; + error_code: string | null; +} + +export interface WorkerProfileDocument { + schema_version: "missioncore.worker-connection-profile/v1"; + profile: WorkerConnectionProfile; + security: { + transport: "ssh"; + host_key_policy: "strict-pinned"; + credentials_managed_by_ui: false; + ssh_host_alias: string; + }; +} + +export interface WorkerRuntime { + name: string; + role: string; + owner: "mission-core" | "external"; + external: boolean; + image: string | null; + state: string; + health: string | null; + cpu_percent: number | null; + memory_percent: number | null; + memory_usage: string | null; + network_io: string | null; + block_io: string | null; + pids: number | null; +} + +export interface WorkerPipelineStage { + id: string; + label: string; + state: "active" | "waiting" | "ready" | "unavailable"; +} + +export interface WorkerTelemetryHistoryRow { + observed_at_utc: string; + cpu_percent: number | null; + memory_percent: number | null; + gpu_percent: number | null; + gpu_memory_percent: number | null; + network_receive_bytes_per_second: number | null; + network_send_bytes_per_second: number | null; +} + +export interface WorkerNetworkInterface { + name: string; + description: string | null; + status: string | null; + link_speed_bps: number | null; + mac_address: string | null; + addresses: string[]; + received_bytes: number | null; + sent_bytes: number | null; +} + +export interface WorkerTelemetry { + schema_version: "missioncore.worker-telemetry/v1"; + profile: WorkerConnectionProfile; + connection: WorkerProbe; + node: { + node_id: string; + observed_at_utc: string; + os: { + caption?: string; + version?: string; + uptime_seconds?: number; + }; + cpu: { + name?: string; + logical_processors?: number; + load_percent?: number; + }; + memory: { + total_bytes: number | null; + used_bytes: number | null; + free_bytes: number | null; + used_percent: number | null; + }; + disks: Array<{ + name?: string; + size_bytes?: number; + free_bytes?: number; + }>; + gpu: { + name?: string; + utilization_percent?: number; + memory_used_mib?: number; + memory_total_mib?: number; + power_watts?: number; + temperature_celsius?: number; + memory_used_percent?: number; + } | null; + triton: { + ready: boolean; + requests_succeeded: number | null; + requests_failed: number | null; + inferences: number | null; + }; + } | null; + runtimes: WorkerRuntime[]; + pipeline: { + service_state: string; + current_stage: string | null; + active_request_id: string | null; + active_frame_index: number | null; + completed_runs: number | null; + failed_runs: number | null; + model_load_seconds: number | null; + stages: WorkerPipelineStage[]; + }; + network: { + interfaces: WorkerNetworkInterface[]; + aggregate: { + received_bytes: number; + sent_bytes: number; + receive_bytes_per_second: number | null; + send_bytes_per_second: number | null; + } | null; + }; + history: WorkerTelemetryHistoryRow[]; +} + +export interface WorkerProfileMutation { + revision: number; + address: string; + port: number; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function requireJsonRecord(value: unknown, schema: string): Record { + if (!isRecord(value) || value.schema_version !== schema) { + throw new Error("Ответ системной телеметрии не соответствует контракту."); + } + return value; +} + +async function requestJson( + url: string, + init: RequestInit, +): Promise { + const response = await fetch(url, { + ...init, + headers: { + Accept: "application/json", + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...init.headers, + }, + }); + if (!response.ok) { + throw new Error(`Системный API вернул HTTP ${response.status}.`); + } + return response.json(); +} + +export async function fetchWorkerTelemetry( + signal?: AbortSignal, +): Promise { + const document = requireJsonRecord( + await requestJson("/api/v1/system/worker-telemetry?history=90", { + method: "GET", + signal, + }), + "missioncore.worker-telemetry/v1", + ); + if ( + !isRecord(document.connection) + || !isRecord(document.profile) + || !Array.isArray(document.runtimes) + || !isRecord(document.pipeline) + || !isRecord(document.network) + || !Array.isArray(document.history) + ) { + throw new Error("Срез Worker 006 неполон."); + } + return document as unknown as WorkerTelemetry; +} + +export async function fetchWorkerProfile( + signal?: AbortSignal, +): Promise { + const document = requireJsonRecord( + await requestJson("/api/v1/system/worker-profile", { + method: "GET", + signal, + }), + "missioncore.worker-connection-profile/v1", + ); + if (!isRecord(document.profile) || !isRecord(document.security)) { + throw new Error("Профиль Worker 006 неполон."); + } + return document as unknown as WorkerProfileDocument; +} + +export async function testWorkerProfile( + request: WorkerProfileMutation, + signal?: AbortSignal, +): Promise { + const document = requireJsonRecord( + await requestJson("/api/v1/system/worker-profile/test", { + method: "POST", + body: JSON.stringify(request), + signal, + }), + "missioncore.worker-probe/v1", + ); + return document as unknown as WorkerProbe; +} + +export async function saveWorkerProfile( + request: WorkerProfileMutation, + signal?: AbortSignal, +): Promise { + const document = requireJsonRecord( + await requestJson("/api/v1/system/worker-profile", { + method: "PUT", + body: JSON.stringify(request), + signal, + }), + "missioncore.worker-connection-profile/v1", + ); + if (!isRecord(document.profile) || !isRecord(document.verification)) { + throw new Error("Сохранённый профиль Worker 006 неполон."); + } + return document as unknown as WorkerProfileDocument & { verification: WorkerProbe }; +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index 890ad8e..e8efb51 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -18,6 +18,8 @@ export type WorkspaceKind = | "missions" | "catalog" | "contour-health" + | "compute-modules" + | "network-monitor" | "datasets" | "lab-archive"; @@ -589,16 +591,17 @@ export const workspaces: WorkspaceDefinition[] = [ label: "Вычислительные модули", title: "Вычислительные модули", eyebrow: "СИСТЕМА / МОДУЛИ", - description: "Контракты будущих вычислительных модулей без их запуска на текущем интерфейсном этапе.", + description: "Живая аппаратная и процессинговая телеметрия выделенного Worker 006.", icon: "apps", - kind: "catalog", + kind: "compute-modules", groups: [ { title: "Вычислительные роли", description: "Каждый модуль публикует стандартные выходы, интерфейс только отображает их.", capabilities: [ active("Адаптер устройства", "Закрытый протокол текущего устройства уже изолирован."), - contract("Восприятие", "Рамки, маски, ключевые точки, траектории и семантические подписи."), + active("Восприятие", "Persistent perception worker и его текущие стадии обработки."), + active("Inference runtime", "Triton health, inference-счётчики и потребление ресурсов."), contract("Картирование", "Высота, занятость, сетки и проходимость."), contract("Локализация", "Поза, одометрия, TF и неопределённость."), contract("Исполнитель миссий", "Состояние графа миссии и действия верхнего уровня."), @@ -637,13 +640,14 @@ export const workspaces: WorkspaceDefinition[] = [ eyebrow: "СИСТЕМА / СЕТЬ", description: "Локальный контур, бортовая сеть, ячеистая сеть/LTE и серверные маршруты.", icon: "globe", - kind: "catalog", + kind: "network-monitor", groups: [ { title: "Сетевые зоны", description: "Потоки управления и высокой пропускной способности разделяются архитектурно.", capabilities: [ - active("Локальная сеть", "Устройство и рабочая станция в одной доступной сети."), + active("Переносимый профиль", "Адрес и порт Worker 006 проверяются до применения."), + active("Сетевая телеметрия", "Интерфейсы, счётчики, throughput и фактический маршрут."), active("Локальное управление", "Точка приёма учётных данных и браузерный контур управления ограничены локальным хостом."), contract("Бортовая сеть", "Сенсоры, вычислитель и маршрутизатор внутри аппарата."), contract("Дальний транспорт", "Ячеистая сеть или LTE передаёт предпросмотр, позу, события и изменения карты."), diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index cb5c4b1..fd665ac 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -11,3 +11,4 @@ @import "./styles/responsive.css"; @import "./styles/observation.css"; @import "./styles/environment-settings.css"; +@import "./styles/system-telemetry.css"; diff --git a/apps/control-station/src/styles/system-telemetry.css b/apps/control-station/src/styles/system-telemetry.css new file mode 100644 index 0000000..8966380 --- /dev/null +++ b/apps/control-station/src/styles/system-telemetry.css @@ -0,0 +1,544 @@ +.system-workspace { + display: grid; + min-width: 0; + gap: 1rem; + padding-bottom: 1rem; +} + +.system-workspace__lead, +.system-section-heading { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 1.2rem; +} + +.system-workspace__lead { + padding: 0.25rem 0 0.35rem; +} + +.system-workspace__lead h2, +.system-section-heading h3 { + margin: 0.4rem 0 0; + color: var(--nodedc-text-primary); + letter-spacing: -0.035em; +} + +.system-workspace__lead h2 { + font-size: 1.42rem; +} + +.system-section-heading h3 { + font-size: 1.02rem; +} + +.system-workspace__lead p, +.system-section-heading p { + max-width: 48rem; + margin: 0.42rem 0 0; + color: var(--nodedc-text-muted); + font-size: 0.68rem; + line-height: 1.48; +} + +.system-workspace__actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 0.8rem; +} + +.system-workspace__notice { + display: flex; + align-items: center; +} + +.system-telemetry-grid, +.network-overview-grid { + display: grid; + min-width: 0; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.65rem; +} + +.system-telemetry-series, +.network-stat-card { + min-width: 0; + border: 1px solid var(--station-hairline); + border-radius: var(--nodedc-radius-card); + background: var(--station-panel-soft); +} + +.system-telemetry-series { + overflow: hidden; + padding: 0.82rem 0.82rem 0.32rem; +} + +.system-telemetry-series > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.system-telemetry-series span, +.network-stat-card span { + color: var(--nodedc-text-muted); + font-size: 0.62rem; +} + +.system-telemetry-series strong, +.network-stat-card strong { + color: var(--nodedc-text-primary); + font-size: 1.14rem; + font-weight: 720; + letter-spacing: -0.035em; +} + +.system-telemetry-series svg { + display: block; + width: 100%; + height: 2.45rem; + margin-top: 0.45rem; +} + +.system-telemetry-series path { + fill: none; + stroke: var(--station-hairline); + stroke-width: 0.8; + vector-effect: non-scaling-stroke; +} + +.system-telemetry-series polyline { + fill: none; + stroke: var(--nodedc-text-primary); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.15; + vector-effect: non-scaling-stroke; +} + +.worker-hardware, +.worker-pipeline, +.network-profile, +.network-topology { + display: grid; + min-width: 0; + gap: 1rem; +} + +.worker-hardware__facts { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.7rem; +} + +.worker-hardware__facts dl, +.worker-runtime-card dl, +.network-interface-card dl { + display: grid; + min-width: 0; + margin: 0; + gap: 0; +} + +.worker-hardware__facts dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + border: 1px solid var(--station-hairline); + border-radius: var(--nodedc-radius-control); +} + +.worker-hardware__facts dl > div, +.worker-runtime-card dl > div, +.network-interface-card dl > div { + display: grid; + min-width: 0; + gap: 0.22rem; +} + +.worker-hardware__facts dl > div { + padding: 0.75rem; +} + +.worker-hardware__facts dt, +.worker-runtime-card dt, +.network-interface-card dt, +.network-profile__security dt { + color: var(--nodedc-text-muted); + font-size: 0.58rem; +} + +.worker-hardware__facts dd, +.worker-runtime-card dd, +.network-interface-card dd, +.network-profile__security dd { + min-width: 0; + margin: 0; + overflow: hidden; + color: var(--nodedc-text-primary); + font-size: 0.67rem; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.worker-disk-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 0.55rem; +} + +.worker-disk-list > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + padding: 0.62rem 0.72rem; + border-radius: var(--nodedc-radius-control); + background: var(--station-accent-soft); +} + +.worker-disk-list span { + color: var(--nodedc-text-muted); + font-size: 0.61rem; +} + +.worker-disk-list strong { + color: var(--nodedc-text-primary); + font-size: 0.64rem; +} + +.system-runtime-section { + display: grid; + min-width: 0; + gap: 0.75rem; +} + +.system-runtime-section--external { + padding-top: 0.25rem; + border-top: 1px solid var(--station-hairline); +} + +.worker-runtime-grid { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.7rem; +} + +.worker-runtime-card { + display: grid; + min-width: 0; + gap: 0.9rem; +} + +.worker-runtime-card[data-external="true"] { + opacity: 0.78; +} + +.worker-runtime-card > header, +.network-interface-card > header { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 0.8rem; +} + +.worker-runtime-card h3 { + margin: 0.38rem 0 0.18rem; + color: var(--nodedc-text-primary); + font-size: 0.9rem; + letter-spacing: -0.025em; +} + +.worker-runtime-card code { + display: block; + max-width: 23rem; + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.56rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.worker-runtime-card dl { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.worker-runtime-card dl > div { + padding-right: 0.65rem; +} + +.worker-pipeline__summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.55rem; +} + +.worker-pipeline__summary > div { + display: grid; + gap: 0.32rem; + padding: 0.7rem; + border-radius: var(--nodedc-radius-control); + background: var(--station-panel-soft); +} + +.worker-pipeline__summary span { + color: var(--nodedc-text-muted); + font-size: 0.57rem; +} + +.worker-pipeline__summary strong { + color: var(--nodedc-text-primary); + font-size: 0.78rem; +} + +.worker-stage-list { + display: grid; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 0; + padding: 0; + gap: 0.48rem; + list-style: none; +} + +.worker-stage-list li { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.55rem; + padding: 0.66rem; + border: 1px solid var(--station-hairline); + border-radius: var(--nodedc-radius-control); + background: var(--station-panel-soft); +} + +.worker-stage-list li[data-state="active"] { + border-color: var(--station-hairline-strong); + background: var(--nodedc-focus-surface); +} + +.worker-stage-list li > span, +.worker-stage-list li > small { + color: var(--nodedc-text-muted); + font-size: 0.53rem; +} + +.worker-stage-list li > strong { + min-width: 0; + overflow: hidden; + color: var(--nodedc-text-primary); + font-size: 0.62rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-stat-card { + display: grid; + align-content: center; + gap: 0.3rem; + padding: 0.82rem; +} + +.network-stat-card small { + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.55rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-profile__form { + display: grid; + min-width: 0; + grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 0.5fr) auto; + align-items: end; + gap: 0.7rem; +} + +.network-profile__buttons { + display: flex; + align-items: center; + gap: 0.5rem; + padding-bottom: 0.02rem; +} + +.network-profile__security { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + gap: 0.55rem; +} + +.network-profile__security > div { + display: grid; + min-width: 0; + gap: 0.24rem; + padding: 0.64rem 0.7rem; + border-radius: var(--nodedc-radius-control); + background: var(--station-accent-soft); +} + +.network-route { + display: grid; + min-width: 0; + grid-template-columns: repeat(3, minmax(8rem, 1fr) 2.2rem) minmax(8rem, 1fr); + align-items: center; +} + +.network-route > div { + display: grid; + min-width: 0; + gap: 0.3rem; + padding: 0.78rem; + border: 1px solid var(--station-hairline); + border-radius: var(--nodedc-radius-control); + background: var(--station-panel-soft); +} + +.network-route > div[data-state="offline"] { + opacity: 0.48; +} + +.network-route > div span, +.network-route > div small { + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.55rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-route > div strong { + overflow: hidden; + color: var(--nodedc-text-primary); + font-size: 0.68rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-route > i { + height: 1px; + background: var(--station-hairline-strong); +} + +.network-route > i[data-state="offline"] { + background: var(--station-hairline); + opacity: 0.4; +} + +.network-interface-section { + display: grid; + gap: 0.75rem; +} + +.network-interface-list { + display: grid; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; +} + +.network-interface-card { + display: grid; + min-width: 0; + gap: 0.85rem; +} + +.network-interface-card header strong, +.network-interface-card header small { + display: block; + max-width: 17rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-interface-card header strong { + color: var(--nodedc-text-primary); + font-size: 0.71rem; +} + +.network-interface-card header small { + margin-top: 0.25rem; + color: var(--nodedc-text-muted); + font-size: 0.55rem; +} + +.network-interface-card dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; +} + +@media (max-width: 1100px) { + .system-telemetry-grid, + .network-overview-grid, + .worker-pipeline__summary, + .network-profile__security { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .worker-stage-list, + .network-interface-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .network-profile__form { + grid-template-columns: minmax(12rem, 1fr) 8rem; + } + + .network-profile__buttons { + grid-column: 1 / -1; + } + + .network-route { + grid-template-columns: 1fr; + gap: 0.4rem; + } + + .network-route > i { + width: 1px; + height: 1.2rem; + margin-left: 1.1rem; + } +} + +@media (max-width: 760px) { + .system-workspace__lead, + .system-section-heading { + display: grid; + } + + .system-workspace__actions { + justify-content: space-between; + } + + .system-telemetry-grid, + .network-overview-grid, + .worker-hardware__facts, + .worker-runtime-grid, + .worker-stage-list, + .network-interface-list, + .network-profile__security { + grid-template-columns: 1fr; + } + + .worker-runtime-card dl, + .worker-pipeline__summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .network-profile__form { + grid-template-columns: 1fr; + } + + .network-profile__buttons { + grid-column: auto; + flex-wrap: wrap; + } +} diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 87969b3..b885dfc 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -46,6 +46,8 @@ import type { WorkspaceRendererProps } from "./contracts"; import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace"; import { ContourHealthWorkspace } from "./ContourHealthWorkspace"; import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace"; +import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace"; +import { NetworkWorkspace } from "./system/NetworkWorkspace"; function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" { if (status === "active") return "success"; @@ -1177,6 +1179,10 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) { state={props.state} /> ); + case "compute-modules": + return ; + case "network-monitor": + return ; case "datasets": return ; case "lab-archive": diff --git a/apps/control-station/src/workspaces/system/ComputeModulesWorkspace.tsx b/apps/control-station/src/workspaces/system/ComputeModulesWorkspace.tsx new file mode 100644 index 0000000..93c5f05 --- /dev/null +++ b/apps/control-station/src/workspaces/system/ComputeModulesWorkspace.tsx @@ -0,0 +1,226 @@ +import { + Button, + GlassSurface, + Icon, + StatusBadge, +} from "@nodedc/ui-react"; + +import { TelemetrySeries } from "../../components/system/TelemetrySeries"; +import { WorkerRuntimeCard } from "../../components/system/WorkerRuntimeCard"; +import { + formatBytes, + formatDuration, + formatOptionalPercent, +} from "../../components/system/systemFormat"; +import { useWorkerTelemetry } from "../../core/system/useWorkerTelemetry"; + +function pipelineStateLabel(state: string): string { + if (state === "busy") return "Выполняет задачу"; + if (state === "ready") return "Готов к задаче"; + return "Нет live-состояния"; +} + +export function ComputeModulesWorkspace() { + const { telemetry, loading, error, refresh } = useWorkerTelemetry(); + const node = telemetry?.node ?? null; + const missionCoreRuntimes = telemetry?.runtimes.filter((runtime) => !runtime.external) ?? []; + const externalRuntimes = telemetry?.runtimes.filter((runtime) => runtime.external) ?? []; + const connected = Boolean( + telemetry?.connection.reachable && telemetry.connection.identity_matches && node, + ); + const history = telemetry?.history ?? []; + const cpuPercent = node?.cpu.load_percent; + const memoryPercent = node?.memory.used_percent; + const gpuPercent = node?.gpu?.utilization_percent; + const gpuMemoryPercent = node?.gpu?.memory_used_percent; + + return ( +
+
+
+ СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ +

Worker 006

+

+ Живой аппаратный и процессинговый срез выделенного узла. Нагрузка внешних + сервисов отделена от Mission Core и не входит в оценку наших runtime. +

+
+
+ + {connected ? "Узел доступен" : "Нет связи с узлом"} + + +
+
+ + {error ? ( + + {error} + + ) : null} + +
+ item.cpu_percent)} + /> + item.memory_percent)} + /> + item.gpu_percent)} + /> + item.gpu_memory_percent)} + /> +
+ + +
+
+ HARDWARE +

{node?.node_id ?? telemetry?.profile.expected_node_id ?? "Worker 006"}

+

{node?.os.caption ?? "Аппаратный профиль недоступен"}

+
+ + {node?.node_id ? telemetry?.profile.display_name : "Нет данных"} + +
+
+
+
Процессор
{node?.cpu.name ?? "—"}
+
Логические ядра
{node?.cpu.logical_processors ?? "—"}
+
Память занята
{formatBytes(node?.memory.used_bytes)}
+
Uptime
{formatDuration(node?.os.uptime_seconds)}
+
+
+
GPU
{node?.gpu?.name ?? "—"}
+
VRAM занята
{formatBytes( + typeof node?.gpu?.memory_used_mib === "number" + ? node.gpu.memory_used_mib * 1024 * 1024 + : null, + )}
+
Температура
{ + typeof node?.gpu?.temperature_celsius === "number" + ? `${node.gpu.temperature_celsius} °C` + : "—" + }
+
Мощность
{ + typeof node?.gpu?.power_watts === "number" + ? `${node.gpu.power_watts.toFixed(1)} Вт` + : "—" + }
+
+
+
+ {(node?.disks ?? []).map((disk) => { + const size = typeof disk.size_bytes === "number" ? disk.size_bytes : null; + const free = typeof disk.free_bytes === "number" ? disk.free_bytes : null; + const used = size !== null && free !== null ? size - free : null; + return ( +
+ Диск {disk.name ?? "—"} + {formatBytes(used)} / {formatBytes(size)} +
+ ); + })} +
+
+ +
+
+
+ PROCESSING RUNTIME +

Контейнеры Mission Core

+

Два ограниченных runtime: inference server и прикладной perception pipeline.

+
+ + {node?.triton.ready ? "Triton ready" : "Triton недоступен"} + +
+
+ {missionCoreRuntimes.map((runtime) => ( + + ))} +
+
+ + +
+
+ ТЕКУЩАЯ ЗАДАЧА +

{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}

+

+ {telemetry?.pipeline.active_request_id + ? `Run ${telemetry.pipeline.active_request_id}` + : "Очередь свободна; модели остаются загруженными в persistent worker."} +

+
+ + {telemetry?.pipeline.service_state ?? "unavailable"} + +
+
+
Завершено запусков{telemetry?.pipeline.completed_runs ?? "—"}
+
Ошибок запусков{telemetry?.pipeline.failed_runs ?? "—"}
+
Inference success{node?.triton.requests_succeeded ?? "—"}
+
Inference failed{node?.triton.requests_failed ?? "—"}
+
+
    + {(telemetry?.pipeline.stages ?? []).map((stage, index) => ( +
  1. + {String(index + 1).padStart(2, "0")} + {stage.label} + { + stage.state === "active" ? "выполняется" + : stage.state === "waiting" ? "в очереди" + : stage.state === "ready" ? "готов" + : "нет данных" + } +
  2. + ))} +
+
+ +
+
+
+ CO-TENANTS / НЕ MISSION CORE +

Внешняя нагрузка узла

+

+ Эти процессы влияют на общую плату и VRAM, но не считаются расходом Mission Core. +

+
+
+
+ {externalRuntimes.map((runtime) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/control-station/src/workspaces/system/NetworkWorkspace.tsx b/apps/control-station/src/workspaces/system/NetworkWorkspace.tsx new file mode 100644 index 0000000..f088238 --- /dev/null +++ b/apps/control-station/src/workspaces/system/NetworkWorkspace.tsx @@ -0,0 +1,311 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Button, + GlassSurface, + Icon, + StatusBadge, + TextField, +} from "@nodedc/ui-react"; + +import { TelemetrySeries } from "../../components/system/TelemetrySeries"; +import { + formatBitRate, + formatBytes, + formatLatency, + formatRate, +} from "../../components/system/systemFormat"; +import { + fetchWorkerProfile, + saveWorkerProfile, + testWorkerProfile, + type WorkerConnectionProfile, + type WorkerProbe, +} from "../../core/system/workerTelemetry"; +import { useWorkerTelemetry } from "../../core/system/useWorkerTelemetry"; + +type ProfileAction = "idle" | "testing" | "saving"; + +function probeLabel(probe: WorkerProbe | null): string { + if (!probe) return "Изменения не проверены"; + if (!probe.reachable) return "Адрес не отвечает"; + if (!probe.identity_matches) return "Ответил другой узел"; + return "Worker 006 подтверждён"; +} + +export function NetworkWorkspace() { + const { telemetry, loading, error, refresh } = useWorkerTelemetry(); + const [profile, setProfile] = useState(null); + const [address, setAddress] = useState(""); + const [port, setPort] = useState("22"); + const [profileAction, setProfileAction] = useState("idle"); + const [probe, setProbe] = useState(null); + const [profileError, setProfileError] = useState(null); + const aggregate = telemetry?.network.aggregate ?? null; + const connected = Boolean( + telemetry?.connection.reachable && telemetry.connection.identity_matches, + ); + + useEffect(() => { + const controller = new AbortController(); + void fetchWorkerProfile(controller.signal) + .then((document) => { + if (controller.signal.aborted) return; + setProfile(document.profile); + setAddress(document.profile.address); + setPort(String(document.profile.port)); + setProfileError(null); + }) + .catch((reason: unknown) => { + if (controller.signal.aborted) return; + setProfileError(reason instanceof Error ? reason.message : "Профиль не прочитан."); + }); + return () => controller.abort(); + }, []); + + const mutation = useMemo(() => { + const numericPort = Number(port); + if (!profile || !Number.isInteger(numericPort) || numericPort < 1 || numericPort > 65535) { + return null; + } + return { + revision: profile.revision, + address: address.trim(), + port: numericPort, + }; + }, [address, port, profile]); + + const runTest = () => { + if (!mutation) return; + const controller = new AbortController(); + setProfileAction("testing"); + setProfileError(null); + setProbe(null); + void testWorkerProfile(mutation, controller.signal) + .then(setProbe) + .catch((reason: unknown) => { + setProfileError(reason instanceof Error ? reason.message : "Проверка не выполнена."); + }) + .finally(() => setProfileAction("idle")); + }; + + const saveProfile = () => { + if (!mutation) return; + const controller = new AbortController(); + setProfileAction("saving"); + setProfileError(null); + void saveWorkerProfile(mutation, controller.signal) + .then((document) => { + setProfile(document.profile); + setAddress(document.profile.address); + setPort(String(document.profile.port)); + setProbe(document.verification); + refresh(); + }) + .catch((reason: unknown) => { + setProfileError(reason instanceof Error ? reason.message : "Профиль не сохранён."); + }) + .finally(() => setProfileAction("idle")); + }; + + return ( +
+
+
+ СИСТЕМА / СЕТЬ +

Локальный вычислительный контур

+

+ Переносимый профиль связи между Mission Core и Worker 006. Адрес можно заменить + при переходе в другую локальную сеть; идентичность узла проверяется до сохранения. +

+
+
+ + {connected ? "Маршрут доступен" : "Маршрут недоступен"} + + +
+
+ + {(error || profileError) ? ( + + {profileError ?? error} + + ) : null} + +
+ item.network_receive_bytes_per_second, + )} + /> + item.network_send_bytes_per_second, + )} + /> +
+ Сбор телеметрии + {formatLatency(telemetry?.connection.latency_ms)} + полный SSH probe Worker 006 +
+
+ Активные интерфейсы + {telemetry?.network.interfaces.length ?? "—"} + по Windows network counters +
+
+ + +
+
+ ПРОФИЛЬ ПОДКЛЮЧЕНИЯ +

Worker 006

+

+ Реальный Node ID: {profile?.expected_node_id ?? "DESKTOP-OPJ8J04"}. + Пустой адрес использует закреплённый локальный SSH-профиль. +

+
+ + {probe ? probeLabel(probe) : connected ? "Текущий профиль работает" : "Не проверено"} + +
+
+ { + setAddress(event.target.value); + setProbe(null); + }} + /> + { + setPort(event.target.value); + setProbe(null); + }} + /> +
+ + +
+
+
+
Транспорт
SSH · key-only
+
Host key
strict · pinned
+
Профиль
{profile?.ssh_host_alias ?? "mission-gpu"}
+
Учётные данные
не доступны интерфейсу
+
+
+ + +
+
+ ФАКТИЧЕСКИЙ МАРШРУТ +

Поток управления и обработки

+

Схема собрана из текущего профиля и live health runtime, без demo-узлов.

+
+
+
+
+ Операторский UI + Browser + 127.0.0.1:8000 +
+