feat: introduce device plugin runtime boundary

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 13:49:10 +03:00
parent 9225227421
commit 27bf7527df
45 changed files with 3748 additions and 1062 deletions
+37 -22
View File
@@ -16,6 +16,7 @@ device adapter, но структура интерфейса от него не
| Контур | Состояние | Что это означает |
| --- | --- | --- |
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Device plugin registry | Реализован, v1alpha1 | До выбора модели provider остаётся inert и не делает I/O; каждый manifest объявляет ровно одну модель, custom `device.connection` UI key и backend factory. Frontend component подключается одним reviewed import в composition root. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
@@ -42,7 +43,8 @@ K1 MQTT :1883, read-only
└── встроенный @rerun-io/web-viewer
└── пространственная сцена Mission Core
Mission Core Control Station ←→ REST /api/* + WebSocket /api/events
Mission Core Control Station ←→ REST /api/v1/device-plugins/*
+ plugin-scoped WebSocket events
FastAPI на 127.0.0.1:8000
CoreBluetooth + live/replay runtime
```
@@ -129,7 +131,7 @@ cd apps/control-station
npm run dev
```
Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на
Vite слушает `http://127.0.0.1:5173` и проксирует весь `/api` (включая WebSocket) на
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
`VITE_API_TARGET`. Preview production-сборки запускается командой
`npm run preview` на `http://127.0.0.1:4173`.
@@ -138,18 +140,20 @@ Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/
1. Запустить `uv run k1link serve` и открыть Mission Core Control Station.
2. Выбрать **Парк → Локальное устройство**.
3. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
4. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
3. В каталоге моделей выбрать **XGRIDS LixelKity K1**. Только после этого
активируется runtime и монтируется custom UI XGRIDS-плагина.
4. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
5. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
оператор.
5. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
reviewed provisioning-запись без автоматических повторов.
6. Запустить live-приём по определённому адресу K1 либо replay локального
7. Запустить live-приём по определённому адресу K1 либо replay локального
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
останавливается подтверждённым двойным нажатием кнопки устройства.
7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
8. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
state. Ручной source вводить не требуется.
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
частота, число точек, задержка и пропуски preview появятся после прихода
сообщений K1.
@@ -197,16 +201,26 @@ Custom timeline, переключатель 2D/3D/карты, семантиче
| Метод | Route | Тело / назначение |
| --- | --- | --- |
| `GET` | `/api/health` | Проверка локального сервиса. |
| `GET` | `/api/state` | Авторитетный snapshot состояния. |
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. |
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. |
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
| `POST` | `/api/session/stop` | Остановка активного источника. |
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
| `WS` | `/api/events` | Периодические snapshots для live UI. |
| `GET` | `/api/v1/device-plugins` | Валидированные manifests установленных device plugins. |
| `GET` | `/api/v1/device-models` | Backend-каталог моделей из валидированных manifests; UI-каталог текущей сборки формируется отдельным static composition root. |
| `POST` | `/api/v1/device-plugins/{pluginId}/actions/{actionId}` | Namespaced действие через host allowlist; тело `{ "input": { ... } }`. |
| `WS` | `/api/v1/device-plugins/{pluginId}/events` | Plugin-scoped snapshots с `pluginId` и монотонным `sequence`. |
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
Frontend XGRIDS-плагина использует только v1alpha namespaced routes. Старые
`/api/state`, `/api/events`, `/api/ble/scan`, `/api/connect`, `/api/session/*` и
`/api/viewer/settings` сохранены как deprecated compatibility shims внутри
XGRIDS backend contribution.
В v1alpha1 backend composition загружается из manifests, а frontend plugins
статически включаются в сборку через `src/composition/devicePlugins.ts`.
Автоматической runtime-сверки двух installed sets пока нет: их соответствие —
проверяемое требование сборки до появления подписанных plugin bundles и startup
compatibility handshake. Поля manifest `permissions`, `mutating` и
`secretFields` пока являются декларативными метаданными; generic host валидирует
их форму и action allowlist, но ещё не реализует на их основе RBAC, подтверждения
оператора или secret-vault substitution.
Legacy `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
@@ -218,14 +232,15 @@ Custom timeline, переключатель 2D/3D/карты, семантиче
| --- | --- |
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
| `src/workspaces/DeviceWorkspace.tsx` | Реальный K1 BLE/Wi-Fi/live/replay adapter UI. |
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle и plugin host. |
| `src/core/runtime/` | Нормализованное состояние активного устройства и spatial source. |
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
| `src/device-plugins/xgrids-k1/` | Реальный K1 BLE/Wi-Fi/live/replay UI, client и compatibility mapper. |
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
| `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
| `src/api.ts` | REST/WebSocket контракт с FastAPI. |
| `src/useK1Console.ts` | Состояние backend, polling, события и действия оператора. |
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
| `src/presentation.ts` | Русские подписи фаз и форматирование реальных метрик. |
| `src/messages.ts` | Обезличивание и локализация технических сообщений в пользовательском интерфейсе. |
| `src/presentation.ts` | Vendor-neutral подписи lifecycle и форматирование нормализованных метрик. |
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
## Safety и чувствительные данные
+34 -31
View File
@@ -26,7 +26,9 @@ import {
} from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage";
import type { ViewerSettings } from "./api";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
import {
rootById,
roots,
@@ -35,14 +37,12 @@ import {
type RootId,
} from "./productModel";
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
import { localizeRuntimeMessage } from "./messages";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "./sceneSettings";
import { useK1Console } from "./useK1Console";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import "./styles/scene-windows.css";
@@ -106,7 +106,8 @@ function mergeViewerSettings(
}
export default function App() {
const console = useK1Console();
const runtime = useMissionRuntime();
const { selection } = useDevicePluginHost();
const workspace = useApplicationWorkspace<string>({
navigationOpen: false,
contentExpanded: true,
@@ -129,17 +130,17 @@ export default function App() {
const activeDefinition = workspaceById(workspace.activeView);
const rootWorkspaces = workspacesForRoot(activeRoot);
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = console.state?.rerun_grpc_url?.trim() ?? "";
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
useEffect(() => {
const remote = console.state?.viewer_settings;
const remote = runtime.state?.viewerSettings;
if (!remote) return;
setSceneSettings((current) => mergeViewerSettings(current, remote));
if (!displayWindowOpen) {
setDisplayDraft((current) => mergeViewerSettings(current, remote));
}
}, [console.state?.viewer_settings, displayWindowOpen]);
}, [runtime.state?.viewerSettings, displayWindowOpen]);
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
setSceneWindowOrder((current) => [
@@ -208,7 +209,7 @@ export default function App() {
};
const applyDisplaySettings = async () => {
const applied = await console.updateViewerSettings(toViewerSettings(displayDraft));
const applied = await runtime.updateViewerSettings(toViewerSettings(displayDraft));
if (applied) setSceneSettings(displayDraft);
};
@@ -217,7 +218,7 @@ export default function App() {
const next = { ...sceneSettings, ...patch };
setSceneSettings(next);
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
const applied = await console.updateViewerSettings(toViewerSettings(next));
const applied = await runtime.updateViewerSettings(toViewerSettings(next));
if (!applied) {
setSceneSettings(previous);
if (!displayWindowOpen) setDisplayDraft(previous);
@@ -231,7 +232,7 @@ export default function App() {
actions.push({
label: "Обновить состояние локального контура",
icon: "refresh",
onClick: () => void console.refresh(),
onClick: () => void runtime.refresh(),
});
}
@@ -249,7 +250,7 @@ export default function App() {
onClick: openLayout,
});
return actions;
}, [activeDefinition?.kind, console, sceneSettings, sourceUrl]);
}, [activeDefinition?.kind, runtime, sceneSettings, sourceUrl]);
const header = (
<AppHeader
@@ -269,9 +270,9 @@ export default function App() {
}
right={
<HeaderProfile>
<HeaderProfileButton onClick={() => void console.refresh()} title="Обновить локальный контур">
<span className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
{backendLabel(console.backendStatus)}
<HeaderProfileButton onClick={() => void runtime.refresh()} title="Обновить локальный контур">
<span className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
{backendLabel(runtime.backendStatus)}
</HeaderProfileButton>
<HeaderAvatar label="DC" />
</HeaderProfile>
@@ -291,9 +292,9 @@ export default function App() {
stage={
<LandingStage
root={currentRoot}
backendStatus={console.backendStatus}
phase={console.state?.phase}
message={localizeRuntimeMessage(console.state?.message)}
backendStatus={runtime.backendStatus}
phase={runtime.state?.phase}
message={runtime.state?.message}
onOpenObservation={() => openView("spatial-scene")}
onOpenDevice={() => openView("local-device")}
/>
@@ -309,9 +310,12 @@ export default function App() {
{
id: "local-contour",
label: "Локальный контур",
description: console.state?.k1_ip || "Устройство не назначено",
description:
runtime.state?.activeDevice?.endpointLabel ||
selection?.model.displayName ||
"Модель не выбрана",
icon: <Icon name="network" />,
active: console.backendStatus !== "offline",
active: runtime.backendStatus !== "offline" && runtime.backendStatus !== "unconfigured",
},
]}
items={rootWorkspaces.map((item) => ({
@@ -341,8 +345,8 @@ export default function App() {
onExpandedChange={workspace.setContentExpanded}
headerTools={
activeDefinition.kind === "device" ? (
<StatusBadge tone={phaseTone(console.state?.phase)}>
{phaseLabel(console.state?.phase)}
<StatusBadge tone={phaseTone(runtime.state?.phase)}>
{phaseLabel(runtime.state?.phase)}
</StatusBadge>
) : activeDefinition.kind === "spatial" ? (
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
@@ -357,14 +361,13 @@ export default function App() {
>
{activeDefinition.kind === "device" ? (
<DeviceWorkspace
console={console}
onOpenSpatialScene={() => openView("spatial-scene")}
/>
) : (
<WorkspaceRenderer
definition={activeDefinition}
state={console.state}
backendStatus={console.backendStatus}
state={runtime.state}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
sceneSettings={sceneSettings}
navigation={{
@@ -482,8 +485,8 @@ export default function App() {
<div className="inspector-control-stack">
<ControlRow label="Контур">
<span className="scene-window-state">
<i className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
{backendLabel(console.backendStatus)}
<i className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
{backendLabel(runtime.backendStatus)}
</span>
</ControlRow>
<p className="scene-window-note">
@@ -520,10 +523,10 @@ export default function App() {
<Button
variant="primary"
shape="pill"
disabled={console.pendingAction === "viewer"}
disabled={runtime.pendingAction === "viewer"}
onClick={() => void applyDisplaySettings()}
>
{console.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
{runtime.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
</Button>
</WindowFooterActions>
}
@@ -660,11 +663,11 @@ export default function App() {
<div className="inspector-control-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
</div>
</div>
),
@@ -675,7 +678,7 @@ export default function App() {
description: "Преобразования и области обзора",
content: (
<div className="inspector-control-stack">
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
@@ -1,13 +1,13 @@
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { RootDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import type { BackendStatus } from "../useK1Console";
export interface LandingStageProps {
root: RootDefinition | null;
backendStatus: BackendStatus;
phase?: string | null;
phase?: RuntimePhase | null;
message?: string | null;
onOpenObservation: () => void;
onOpenDevice: () => void;
@@ -0,0 +1,8 @@
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
import { xgridsK1Plugin } from "../device-plugins/xgrids-k1/plugin";
// Composition root: this is the only place where Mission Core chooses which
// statically reviewed device plugins are shipped in the current build.
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
xgridsK1Plugin,
]);
@@ -0,0 +1,160 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
interface DevicePluginHostValue {
registry: DevicePluginRegistry;
selection: RegisteredDeviceModel | null;
selectionTransitionPending: boolean;
selectionTransitionError: string | null;
selectModel: (modelId: string) => Promise<boolean>;
clearSelection: () => Promise<boolean>;
}
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
export function DevicePluginHostProvider({
plugins,
children,
}: {
plugins: readonly DeviceUiPlugin[];
children: ReactNode;
}) {
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
const transitionInFlight = useRef(false);
const deactivationHandlers = useRef(new Map<string, () => Promise<boolean>>());
const selection = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const registerDeactivation = useCallback(
(pluginId: string, handler: () => Promise<boolean>) => {
deactivationHandlers.current.set(pluginId, handler);
return () => {
if (deactivationHandlers.current.get(pluginId) === handler) {
deactivationHandlers.current.delete(pluginId);
}
};
},
[],
);
const transitionTo = useCallback(
async (nextModelId: string | null): Promise<boolean> => {
if (transitionInFlight.current) return false;
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
}
if (nextModelId === selectedModelId) return true;
transitionInFlight.current = true;
setSelectionTransitionPending(true);
setSelectionTransitionError(null);
try {
const current = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const deactivate = current
? deactivationHandlers.current.get(current.plugin.manifest.metadata.id)
: undefined;
if (current && !deactivate) {
setSelectionTransitionError(
"Активный плагин не зарегистрировал безопасное завершение сессии.",
);
return false;
}
if (deactivate) {
try {
if (!(await deactivate())) {
setSelectionTransitionError(
"Плагин не подтвердил завершение текущей сессии.",
);
return false;
}
} catch {
setSelectionTransitionError(
"Не удалось безопасно завершить текущую сессию плагина.",
);
return false;
}
}
setSelectedModelId(nextModelId);
return true;
} finally {
transitionInFlight.current = false;
setSelectionTransitionPending(false);
}
},
[registry, selectedModelId],
);
const deactivationRegistrars = useMemo(
() =>
new Map(
registry.plugins.map((plugin) => [
plugin.manifest.metadata.id,
(handler: () => Promise<boolean>) =>
registerDeactivation(plugin.manifest.metadata.id, handler),
]),
),
[registerDeactivation, registry.plugins],
);
const value = useMemo<DevicePluginHostValue>(
() => ({
registry,
selection,
selectionTransitionPending,
selectionTransitionError,
selectModel: (modelId) => transitionTo(modelId),
clearSelection: () => transitionTo(null),
}),
[
registry,
selection,
selectionTransitionError,
selectionTransitionPending,
transitionTo,
],
);
const runtimeTree = [...registry.plugins].reverse().reduce<ReactNode>((child, plugin) => {
const RuntimeProvider = plugin.RuntimeProvider;
return (
<RuntimeProvider
key={plugin.manifest.metadata.id}
activeModel={
selection?.plugin.manifest.metadata.id === plugin.manifest.metadata.id
? selection.model
: null
}
registerDeactivation={deactivationRegistrars.get(plugin.manifest.metadata.id)!}
>
{child}
</RuntimeProvider>
);
}, children);
return (
<DevicePluginHostContext.Provider value={value}>
<IdleMissionRuntimeProvider>{runtimeTree}</IdleMissionRuntimeProvider>
</DevicePluginHostContext.Provider>
);
}
export function useDevicePluginHost(): DevicePluginHostValue {
const value = useContext(DevicePluginHostContext);
if (!value) {
throw new Error("DevicePluginHostProvider не подключён в composition root.");
}
return value;
}
@@ -0,0 +1,74 @@
import type { ComponentType, ReactNode } from "react";
export const DEVICE_PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1" as const;
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
export interface DeviceCapability {
id: string;
label: string;
}
export interface DevicePluginActionDefinition {
id: string;
mutating: boolean;
secretFields: readonly string[];
}
export interface DeviceModelDefinition {
id: string;
vendor: string;
displayName: string;
category: string;
description: string;
verified: boolean;
capabilities: readonly DeviceCapability[];
ui: {
slot: "device.connection";
componentKey: string;
};
}
export interface DevicePluginManifest {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION;
kind: "DevicePlugin";
metadata: {
id: string;
version: string;
displayName: string;
};
spec: {
hostApiRange: "v1alpha1";
runtime: {
backendEntrypoint: string;
isolation: "transitional-in-process";
};
permissions: readonly string[];
actions: readonly DevicePluginActionDefinition[];
models: readonly DeviceModelDefinition[];
};
}
export interface DevicePluginHostActions {
openSpatialScene: () => void;
}
export interface DevicePluginConnectionProps {
model: DeviceModelDefinition;
host: DevicePluginHostActions;
}
export interface DeviceUiPlugin {
manifest: DevicePluginManifest;
RuntimeProvider: ComponentType<{
activeModel: DeviceModelDefinition | null;
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
children: ReactNode;
}>;
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
}
export interface RegisteredDeviceModel {
plugin: DeviceUiPlugin;
model: DeviceModelDefinition;
ConnectionView: ComponentType<DevicePluginConnectionProps>;
}
@@ -0,0 +1,182 @@
import {
DEVICE_PLUGIN_API_VERSION,
type DeviceCapability,
type DeviceModelDefinition,
type DevicePluginActionDefinition,
type DevicePluginManifest,
} from "./contracts";
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
path: string,
allowed: readonly string[],
): void {
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
if (extras.length) {
throw new Error(`Некорректный manifest: ${path} содержит неизвестные поля ${extras.join(", ")}.`);
}
}
function text(value: unknown, path: string, maxLength = 160): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`Некорректный manifest: ${path} должен быть непустой строкой.`);
}
if (value.length > maxLength) {
throw new Error(
`Некорректный manifest: ${path} длиннее ${maxLength} символов.`,
);
}
return value;
}
function flag(value: unknown, path: string): boolean {
if (typeof value !== "boolean") {
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
}
return value;
}
function list(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть массивом.`);
}
return value;
}
function capability(value: unknown, path: string): DeviceCapability {
const item = record(value, path);
exactKeys(item, path, ["id", "label"]);
return { id: text(item.id, `${path}.id`), label: text(item.label, `${path}.label`) };
}
function action(value: unknown, path: string): DevicePluginActionDefinition {
const item = record(value, path);
exactKeys(item, path, ["id", "mutating", "secretFields"]);
return {
id: text(item.id, `${path}.id`),
mutating: flag(item.mutating, `${path}.mutating`),
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
text(field, `${path}.secretFields[${index}]`),
),
};
}
function model(value: unknown, path: string): DeviceModelDefinition {
const item = record(value, path);
exactKeys(item, path, [
"id",
"vendor",
"displayName",
"category",
"description",
"verified",
"capabilities",
"ui",
]);
const ui = record(item.ui, `${path}.ui`);
exactKeys(ui, `${path}.ui`, ["slot", "componentKey"]);
const slot = text(ui.slot, `${path}.ui.slot`);
if (slot !== "device.connection") {
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
}
return {
id: text(item.id, `${path}.id`),
vendor: text(item.vendor, `${path}.vendor`),
displayName: text(item.displayName, `${path}.displayName`),
category: text(item.category, `${path}.category`),
description: text(item.description, `${path}.description`, 1024),
verified: flag(item.verified, `${path}.verified`),
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
capability(entry, `${path}.capabilities[${index}]`),
),
ui: {
slot,
componentKey: text(ui.componentKey, `${path}.ui.componentKey`),
},
};
}
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
const root = record(document, "root");
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
if (root.apiVersion !== DEVICE_PLUGIN_API_VERSION || root.kind !== "DevicePlugin") {
throw new Error("Manifest использует несовместимую версию или kind.");
}
const metadata = record(root.metadata, "metadata");
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
const spec = record(root.spec, "spec");
exactKeys(spec, "spec", [
"hostApiRange",
"runtime",
"permissions",
"actions",
"models",
]);
if (spec.hostApiRange !== "v1alpha1") {
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
}
const runtime = record(spec.runtime, "spec.runtime");
exactKeys(runtime, "spec.runtime", ["backendEntrypoint", "isolation"]);
const isolation = text(runtime.isolation, "spec.runtime.isolation");
if (isolation !== "transitional-in-process") {
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
}
const models = list(spec.models, "spec.models").map((entry, index) =>
model(entry, `spec.models[${index}]`),
);
if (models.length !== 1) {
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
}
const version = text(metadata.version, "metadata.version");
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
}
return {
apiVersion: DEVICE_PLUGIN_API_VERSION,
kind: "DevicePlugin",
metadata: {
id: text(metadata.id, "metadata.id"),
version,
displayName: text(metadata.displayName, "metadata.displayName"),
},
spec: {
hostApiRange: "v1alpha1",
runtime: {
backendEntrypoint: text(
runtime.backendEntrypoint,
"spec.runtime.backendEntrypoint",
256,
),
isolation,
},
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
text(entry, `spec.permissions[${index}]`),
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${index}]`),
),
models,
},
};
}
export function requirePluginAction(
manifest: DevicePluginManifest,
actionId: string,
): string {
const action = manifest.spec.actions.find((candidate) => candidate.id === actionId);
if (!action) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет действие ${actionId}.`);
}
return action.id;
}
@@ -0,0 +1,95 @@
import {
DEVICE_PLUGIN_API_VERSION,
DEVICE_STATE_READ_ACTION_ID,
type DeviceUiPlugin,
type RegisteredDeviceModel,
} from "./contracts";
export interface DevicePluginRegistry {
readonly plugins: readonly DeviceUiPlugin[];
readonly models: readonly RegisteredDeviceModel[];
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
}
export function createDevicePluginRegistry(
installedPlugins: readonly DeviceUiPlugin[],
): DevicePluginRegistry {
const pluginIds = new Set<string>();
const modelIds = new Set<string>();
const models: RegisteredDeviceModel[] = [];
for (const plugin of installedPlugins) {
const { manifest } = plugin;
if (manifest.apiVersion !== DEVICE_PLUGIN_API_VERSION) {
throw new Error(
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
);
}
if (manifest.kind !== "DevicePlugin") {
throw new Error(`Неподдерживаемый kind плагина: ${manifest.kind}.`);
}
if (!manifest.metadata.id.trim() || pluginIds.has(manifest.metadata.id)) {
throw new Error(
`Идентификатор плагина пуст или повторяется: ${manifest.metadata.id || "<empty>"}.`,
);
}
pluginIds.add(manifest.metadata.id);
if (manifest.spec.models.length !== 1) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
);
}
const permissions = new Set(manifest.spec.permissions);
if (permissions.size !== manifest.spec.permissions.length) {
throw new Error(`Плагин ${manifest.metadata.id} повторяет permission.`);
}
const actionIds = new Set<string>();
for (const action of manifest.spec.actions) {
if (!action.id.trim() || actionIds.has(action.id)) {
throw new Error(
`Идентификатор действия пуст или повторяется: ${action.id || "<empty>"}.`,
);
}
actionIds.add(action.id);
if (new Set(action.secretFields).size !== action.secretFields.length) {
throw new Error(`Действие ${action.id} повторяет secret field.`);
}
}
const stateRead = manifest.spec.actions.find(
(action) => action.id === DEVICE_STATE_READ_ACTION_ID,
);
if (!stateRead || stateRead.mutating || stateRead.secretFields.length) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять безопасное действие state.read.`,
);
}
for (const model of manifest.spec.models) {
if (!model.id.trim() || modelIds.has(model.id)) {
throw new Error(`Идентификатор модели пуст или повторяется: ${model.id || "<empty>"}.`);
}
modelIds.add(model.id);
const capabilityIds = new Set(model.capabilities.map((capability) => capability.id));
if (capabilityIds.size !== model.capabilities.length) {
throw new Error(`Модель ${model.id} повторяет capability.`);
}
const ConnectionView = plugin.connectionViews[model.ui.componentKey];
if (!ConnectionView) {
throw new Error(
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
);
}
models.push({ plugin, model, ConnectionView });
}
}
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
return {
plugins: Object.freeze([...installedPlugins]),
models: Object.freeze(models),
resolveModel: (modelId) => modelById.get(modelId) ?? null,
};
}
@@ -0,0 +1,35 @@
import { createContext, useContext, type ReactNode } from "react";
import type { MissionRuntimeController } from "./contracts";
const idleRuntime: MissionRuntimeController = {
state: {
phase: "unconfigured",
message: "Сначала выберите модель локального устройства.",
sourceMode: "idle",
},
backendStatus: "unconfigured",
pendingAction: null,
refresh: () => undefined,
updateViewerSettings: async () => false,
};
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
export function MissionRuntimeProvider({
value,
children,
}: {
value: MissionRuntimeController;
children: ReactNode;
}) {
return <MissionRuntimeContext.Provider value={value}>{children}</MissionRuntimeContext.Provider>;
}
export function IdleMissionRuntimeProvider({ children }: { children: ReactNode }) {
return <MissionRuntimeProvider value={idleRuntime}>{children}</MissionRuntimeProvider>;
}
export function useMissionRuntime(): MissionRuntimeController {
return useContext(MissionRuntimeContext);
}
@@ -0,0 +1,65 @@
export type BackendStatus = "unconfigured" | "checking" | "online" | "degraded" | "offline";
export type RuntimePhase =
| "unconfigured"
| "idle"
| "configuring"
| "connected"
| "starting"
| "streaming"
| "replaying"
| "stopping"
| "error";
export type SourceMode = "idle" | "live" | "replay";
export interface ViewerSettings {
point_size: number;
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
}
export interface StreamMetrics {
latencyMs?: number | null;
frameRateHz?: number | null;
pointCount?: number | null;
droppedPreviewFrames?: number | null;
}
export interface ActiveDeviceSnapshot {
pluginId: string;
modelId: string;
displayName: string;
instanceId?: string | null;
endpointLabel?: string | null;
}
export interface SpatialSourceDescriptor {
id: string;
url: string;
label: string;
kind: "rerun-grpc" | "rrd" | "other";
}
export interface MissionRuntimeState {
phase: RuntimePhase;
message?: string | null;
activeDevice?: ActiveDeviceSnapshot | null;
spatialSource?: SpatialSourceDescriptor | null;
viewerSettings?: ViewerSettings | null;
sourceMode: SourceMode;
metrics?: StreamMetrics;
}
export interface MissionRuntimeController {
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
pendingAction: string | null;
refresh: () => void | Promise<void>;
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
}
@@ -0,0 +1,551 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Button,
Checker,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "../../core/device-plugins/contracts";
import { MetricCard } from "../../components/MetricCard";
import type { BleDevice } from "./api";
import { localizeRuntimeMessage } from "./messages";
import {
backendLabel,
backendTone,
eventStatusLabel,
finiteMetric,
formatNumber,
phaseLabel,
phaseTone,
pipelineLatency,
sourceModeLabel,
} from "./presentation";
import { useXgridsK1Controller } from "./runtimeContext";
type SessionIntent = "live" | "replay";
const sessionItems = [
{ value: "live", label: "Реальное устройство" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function WizardStep({
number,
title,
status,
tone = "neutral",
children,
}: {
number: string;
title: string;
status: string;
tone?: StatusTone;
children: ReactNode;
}) {
return (
<section className="wizard-step">
<div className="wizard-step__rail" aria-hidden="true">
<span>{number}</span>
</div>
<div className="wizard-step__content">
<header>
<h3>{title}</h3>
<StatusBadge tone={tone}>{status}</StatusBadge>
</header>
{children}
</div>
</section>
);
}
function DeviceRow({
device,
selected,
onSelect,
}: {
device: BleDevice;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
className="device-row"
data-compatible={device.likely_k1 ? "true" : undefined}
data-selected={selected ? "true" : undefined}
>
<div className="device-row__identity">
<span className="device-row__signal" aria-hidden="true" />
<div>
<span className="device-row__name">
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
</span>
<code>{device.device_id}</code>
</div>
</div>
<div className="device-row__action">
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
<Button
size="compact"
variant={selected ? "primary" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Выбрано" : "Выбрать"}
</Button>
</div>
</div>
);
}
function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
{values.length ? (
values.map((value, index) => (
<span
key={`${index}-${value}`}
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
title={`${value.toFixed(1)} мс`}
/>
))
) : (
<p>Измерений пока нет. График появится после получения реальных данных.</p>
)}
</div>
);
}
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
const console = useXgridsK1Controller();
const {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh,
clearError,
scan,
connect,
startLive,
startReplay,
stop,
} = console;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
useEffect(() => {
if (state?.selected_device_id) {
setSelectedDeviceId(state.selected_device_id);
return;
}
if (
selectedDeviceId &&
state?.devices &&
!state.devices.some((device) => device.device_id === selectedDeviceId)
) {
setSelectedDeviceId("");
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
const sourceLabel = sourceModeLabel(state?.source_mode);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const submitConnect = async () => {
if (!canConnect) return;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
});
if (succeeded) setPassword("");
};
const submitLive = async () => {
const targetHost = liveHost.trim();
const started = await startLive(targetHost ? { host: targetHost } : {});
if (started) host.openSpatialScene();
};
const submitReplay = async () => {
const speed = Number(replaySpeed);
const started = await startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
if (started) host.openSpatialScene();
};
return (
<div className="device-workspace xgrids-k1-plugin">
{error ? (
<aside className="error-banner" role="alert">
<span className="error-banner__dot" aria-hidden="true" />
<div>
<strong>Локальная операция завершилась ошибкой</strong>
<p>{localizeRuntimeMessage(error)}</p>
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
Повторить
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Закрыть
</Button>
</div>
</aside>
) : null}
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
<h2>Подключение {model.displayName}</h2>
<p>
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
операторская модель от конкретного устройства не зависят.
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение адаптера"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Реальное число декодированных точек"
/>
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
/>
</section>
<div className="device-workspace__grid">
<GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
<h2>Подключите устройство к сети</h2>
</div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
</header>
<div className="wizard-list">
<WizardStep
number="01"
title="Включите устройство"
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
tone={powerConfirmed ? "success" : "warning"}
>
<div className="nodedc-field">
<span className="nodedc-field__description">
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
</span>
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
onChange={setPowerConfirmed}
/>
</div>
</WizardStep>
<WizardStep
number="02"
title="Выберите Bluetooth-устройство"
status={
pendingAction === "scan"
? "Поиск…"
: selectedDeviceId
? "Устройство выбрано"
: `Найдено: ${devices.length}`
}
tone={
pendingAction === "scan"
? "accent"
: selectedDeviceId
? "success"
: "neutral"
}
>
<p className="step-copy">
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
профиль — только подсказка; окончательный выбор всегда делает оператор.
</p>
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy}
onClick={() => {
setSelectedDeviceId("");
void scan();
}}
>
{pendingAction === "scan"
? "Сканируем Bluetooth — 6 секунд…"
: "Показать все BLE-устройства"}
</Button>
<div className="device-list">
{devices.length ? (
devices.map((device) => (
<DeviceRow
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => setSelectedDeviceId(device.device_id)}
/>
))
) : (
<div className="empty-device-list">
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
повторите поиск.
</div>
)}
</div>
</WizardStep>
<WizardStep
number="03"
title="Передайте настройки Wi‑Fi"
status={state?.k1_ip ? "Подключено" : "Не подключено"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Название сети Wi‑Fi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
/>
<TextField
label="Пароль Wi‑Fi"
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
placeholder="Введите пароль"
/>
</div>
<div className="connection-summary">
<span>Устройство</span>
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
</div>
<Button
width="full"
variant="primary"
icon={<Icon name="network" />}
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
</Button>
<p className="safety-note">
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
удаляется из формы после успешного подключения.
</p>
</WizardStep>
</div>
</GlassSurface>
<div className="device-workspace__side">
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">
{sessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
/>
{sessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
onClick={() => void submitLive()}
>
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
</p>
</div>
) : (
<div className="session-form session-form--replay">
<TextField
label="Путь к записи"
hint="Локальный файл исходных данных"
value={replayPath}
onChange={(event) => setReplayPath(event.target.value)}
spellCheck={false}
placeholder="sessions/.../capture.tsv"
/>
<TextField
label="Скорость повтора"
hint="Множитель"
type="number"
min="0.1"
step="0.1"
value={replaySpeed}
onChange={(event) => setReplaySpeed(event.target.value)}
/>
<div className="nodedc-field">
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
<Checker
checked={replayLoop}
label="Повторять по кругу"
onChange={setReplayLoop}
/>
</div>
<Button
variant="primary"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
</Button>
</div>
)}
<div className="session-footer">
<p>Статус изменится только после ответа локального сервиса.</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
</Button>
</div>
</GlassSurface>
<div className="diagnostics-grid">
<GlassSurface className="status-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
<h2>Локальный контур</h2>
</div>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</header>
<dl className="detail-list">
<DetailRow label="Канал событий">
<span className="inline-state" data-state={eventStatus}>
{eventStatusLabel(eventStatus)}
</span>
</DetailRow>
<DetailRow label="Источник">{sourceLabel}</DetailRow>
<DetailRow label="Адрес устройства">
<code>{state?.k1_ip || "Не получен"}</code>
</DetailRow>
</dl>
</GlassSurface>
<GlassSurface className="latency-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
<h2>Время до публикации</h2>
</div>
<strong className="latency-now">
{formatNumber(latency)} <span>мс</span>
</strong>
</header>
<LatencyTrace values={latencyHistory} />
<div className="latency-legend">
<span>Старые</span>
<span>Последние</span>
</div>
</GlassSurface>
</div>
</div>
</div>
</div>
);
}
@@ -1,3 +1,8 @@
import type { ViewerSettings } from "../../core/runtime/contracts";
import { xgridsK1Actions, xgridsK1Manifest } from "./manifest";
const PLUGIN_ID = xgridsK1Manifest.metadata.id;
export interface BleDevice {
device_id: string;
name?: string | null;
@@ -9,7 +14,7 @@ export interface BleDevice {
export type SourceMode = "idle" | "live" | "replay";
export interface K1Metrics {
export interface XgridsK1Metrics {
mqtt_to_decode_ms?: number | null;
decode_ms?: number | null;
publish_ms?: number | null;
@@ -22,7 +27,7 @@ export interface K1Metrics {
[key: string]: number | null | undefined;
}
export interface ConsoleState {
export interface XgridsK1State {
phase?: string | null;
message?: string | null;
devices?: BleDevice[];
@@ -33,18 +38,7 @@ export interface ConsoleState {
rerun_grpc_url?: string | null;
viewer_settings?: ViewerSettings | null;
source_mode?: SourceMode | null;
metrics?: K1Metrics;
}
export interface ViewerSettings {
point_size: number;
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
metrics?: XgridsK1Metrics;
}
export interface HealthResponse {
@@ -89,14 +83,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function unwrapState(payload: unknown): ConsoleState {
function unwrapState(payload: unknown): XgridsK1State {
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
if (!isRecord(value)) {
throw new ApiError("Локальный сервер вернул некорректное состояние.");
}
return value as ConsoleState;
return value as XgridsK1State;
}
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
@@ -142,20 +136,27 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
return body;
}
async function postState(path: string, body?: object): Promise<ConsoleState> {
async function postState(path: string, body?: object): Promise<XgridsK1State> {
const payload = await requestJson(path, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
if (payload === undefined) {
return api.getState();
return xgridsK1Api.getState();
}
return unwrapState(payload);
}
export const api = {
function invokeState(actionId: string, input: object = {}): Promise<XgridsK1State> {
return postState(
`/api/v1/device-plugins/${encodeURIComponent(PLUGIN_ID)}/actions/${encodeURIComponent(actionId)}`,
{ input },
);
}
export const xgridsK1Api = {
async getHealth(): Promise<HealthResponse> {
const payload = await requestJson("/api/health");
if (!isRecord(payload)) {
@@ -164,45 +165,48 @@ export const api = {
return payload as HealthResponse;
},
async getState(): Promise<ConsoleState> {
return unwrapState(await requestJson("/api/state"));
async getState(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.stateRead);
},
scanBle(body: ScanRequest = {}): Promise<ConsoleState> {
return postState("/api/ble/scan", body);
scanBle(body: ScanRequest = {}): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.discoveryScan, body);
},
connect(body: ConnectRequest): Promise<ConsoleState> {
return postState("/api/connect", body);
connect(body: ConnectRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.networkProvision, body);
},
startLive(body: LiveRequest = {}): Promise<ConsoleState> {
return postState("/api/session/live", body);
startLive(body: LiveRequest = {}): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStartLive, body);
},
startReplay(body: ReplayRequest): Promise<ConsoleState> {
return postState("/api/session/replay", body);
startReplay(body: ReplayRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStartReplay, body);
},
stopSession(): Promise<ConsoleState> {
return postState("/api/session/stop");
stopSession(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStop);
},
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> {
return postState("/api/viewer/settings", body);
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.viewerSettingsUpdate, body);
},
};
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
function eventSocketUrl(): string {
const url = new URL("/api/events", window.location.href);
const url = new URL(
`/api/v1/device-plugins/${encodeURIComponent(PLUGIN_ID)}/events`,
window.location.href,
);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
export function openEventSocket(
onState: (state: ConsoleState) => void,
onState: (state: XgridsK1State) => void,
onStatus: (status: EventSocketStatus) => void,
): () => void {
onStatus("connecting");
@@ -0,0 +1,19 @@
import manifestDocument from "../../../../../plugins/xgrids-k1/plugin.manifest.json";
import { DEVICE_STATE_READ_ACTION_ID } from "../../core/device-plugins/contracts";
import {
parseDevicePluginManifest,
requirePluginAction,
} from "../../core/device-plugins/manifestParser";
export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument);
export const xgridsK1Actions = Object.freeze({
stateRead: requirePluginAction(xgridsK1Manifest, DEVICE_STATE_READ_ACTION_ID),
discoveryScan: requirePluginAction(xgridsK1Manifest, "discovery.scan"),
networkProvision: requirePluginAction(xgridsK1Manifest, "network.provision"),
streamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
streamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
});
@@ -0,0 +1,12 @@
import type { DeviceUiPlugin } from "../../core/device-plugins/contracts";
import { XgridsK1Connection } from "./XgridsK1Connection";
import { xgridsK1Manifest } from "./manifest";
import { XgridsK1RuntimeProvider } from "./runtimeContext";
export const xgridsK1Plugin: DeviceUiPlugin = {
manifest: xgridsK1Manifest,
RuntimeProvider: XgridsK1RuntimeProvider,
connectionViews: Object.freeze({
"xgrids-k1.connection": XgridsK1Connection,
}),
};
@@ -0,0 +1,85 @@
import type { StatusTone } from "@nodedc/ui-react";
import type { BackendStatus } from "../../core/runtime/contracts";
import type { XgridsK1Metrics } from "./api";
const phaseLabels: Record<string, string> = {
idle: "Ожидание",
scanning: "Поиск Bluetooth",
device_selected: "Устройство выбрано",
provisioning: "Передача настроек Wi‑Fi",
connecting: "Подключение",
connected: "Устройство подключено",
starting_live: "Запуск потока",
live: "Поток в реальном времени",
replay: "Повтор записи",
stopping: "Остановка",
error: "Ошибка",
};
export function phaseLabel(phase: string | null | undefined): string {
if (!phase) return "Нет состояния";
return phaseLabels[phase] ?? "Неизвестное состояние";
}
export function phaseTone(phase: string | null | undefined): StatusTone {
if (!phase) return "neutral";
if (phase === "error") return "danger";
if (["connected", "live", "replay"].includes(phase)) return "success";
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) return "accent";
return "neutral";
}
export function backendLabel(status: BackendStatus): string {
return {
unconfigured: "Модель не выбрана",
checking: "Проверка контура",
online: "Контур доступен",
degraded: "Контур ограничен",
offline: "Контур недоступен",
}[status];
}
export function backendTone(status: BackendStatus): StatusTone {
if (status === "online") return "success";
if (status === "degraded" || status === "checking") return "warning";
if (status === "unconfigured") return "neutral";
return "danger";
}
export function eventStatusLabel(status: string): string {
return {
connecting: "подключение",
open: "подключён",
closed: "закрыт",
error: "ошибка",
}[status] ?? "неизвестно";
}
export function finiteMetric(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number | null {
if (!metrics) return null;
const direct = finiteMetric(metrics.pipeline_ms ?? metrics.end_to_end_ms);
if (direct !== null) return direct;
const segments = [finiteMetric(metrics.mqtt_to_decode_ms), finiteMetric(metrics.publish_ms)]
.filter((value): value is number => value !== null);
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
}
export function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("ru-RU", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
export function sourceModeLabel(mode: string | null | undefined): string {
if (mode === "live") return "Реальное время";
if (mode === "replay") return "Повтор записи";
if (mode === "idle") return "Ожидание";
return "Неизвестно";
}
@@ -0,0 +1,126 @@
import { createContext, useContext, useEffect, type ReactNode } from "react";
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
import {
MissionRuntimeProvider,
useMissionRuntime,
} from "../../core/runtime/MissionRuntimeContext";
import type {
MissionRuntimeController,
MissionRuntimeState,
RuntimePhase,
} from "../../core/runtime/contracts";
import { localizeRuntimeMessage } from "./messages";
import { xgridsK1Manifest } from "./manifest";
import { finiteMetric, pipelineLatency } from "./presentation";
import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
const XgridsK1RuntimeContext = createContext<XgridsK1Controller | null>(null);
function normalizePhase(phase: string | null | undefined): RuntimePhase {
if (phase === "error") return "error";
if (phase === "connected") return "connected";
if (phase === "starting_live") return "starting";
if (phase === "live") return "streaming";
if (phase === "replay") return "replaying";
if (phase === "stopping") return "stopping";
if (["scanning", "device_selected", "provisioning", "connecting"].includes(phase ?? "")) {
return "configuring";
}
return "idle";
}
function normalizeState(
controller: XgridsK1Controller,
activeModel: DeviceModelDefinition,
): MissionRuntimeState | null {
const state = controller.state;
if (!state) return null;
const metrics = state.metrics;
const hasDevice = Boolean(state.selected_device_id || state.k1_ip);
const sourceUrl = state.rerun_grpc_url?.trim() ?? "";
return {
phase: normalizePhase(state.phase),
message: localizeRuntimeMessage(state.message),
activeDevice: hasDevice
? {
pluginId: xgridsK1Manifest.metadata.id,
modelId: activeModel.id,
displayName: activeModel.displayName,
instanceId: state.selected_device_id,
endpointLabel: state.k1_ip,
}
: null,
spatialSource: sourceUrl
? {
id: "xgrids-k1-rerun-live",
url: sourceUrl,
label: "Локальный пространственный поток",
kind: "rerun-grpc",
}
: null,
viewerSettings: state.viewer_settings,
sourceMode: state.source_mode ?? "idle",
metrics: {
latencyMs: pipelineLatency(metrics),
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),
pointCount: finiteMetric(metrics?.point_count),
droppedPreviewFrames: finiteMetric(metrics?.dropped_preview_frames),
},
};
}
export function XgridsK1RuntimeProvider({
activeModel,
registerDeactivation,
children,
}: {
activeModel: DeviceModelDefinition | null;
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
children: ReactNode;
}) {
const active = activeModel !== null;
const inheritedRuntime = useMissionRuntime();
const controller = useXgridsK1Runtime(active);
const missionRuntime: MissionRuntimeController = {
state: activeModel ? normalizeState(controller, activeModel) : null,
backendStatus: controller.backendStatus,
pendingAction: controller.pendingAction,
refresh: controller.refresh,
updateViewerSettings: controller.updateViewerSettings,
};
useEffect(() => {
if (!activeModel) return;
return registerDeactivation(async () => {
if (controller.pendingAction !== null) return false;
// Always ask the backend to stop. The browser snapshot may be stale or not
// loaded yet, while a previous local capture is still alive.
return controller.stop();
});
}, [
activeModel,
controller.pendingAction,
controller.stop,
registerDeactivation,
]);
return (
<XgridsK1RuntimeContext.Provider value={active ? controller : null}>
<MissionRuntimeProvider value={active ? missionRuntime : inheritedRuntime}>
{children}
</MissionRuntimeProvider>
</XgridsK1RuntimeContext.Provider>
);
}
export function useXgridsK1Controller(): XgridsK1Controller {
const controller = useContext(XgridsK1RuntimeContext);
if (!controller) {
throw new Error("XGRIDS K1 runtime используется вне собственного plugin provider.");
}
return controller;
}
@@ -1,19 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { BackendStatus, ViewerSettings } from "../../core/runtime/contracts";
import {
ApiError,
api,
xgridsK1Api,
openEventSocket,
type ConnectRequest,
type ConsoleState,
type XgridsK1State,
type EventSocketStatus,
type LiveRequest,
type ReplayRequest,
type ViewerSettings,
} from "./api";
import { localizeRuntimeMessage } from "./messages";
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
function messageFor(error: unknown): string {
@@ -26,7 +26,7 @@ function messageFor(error: unknown): string {
return "Запрос к локальному сервису устройства завершился ошибкой.";
}
function measuredLatency(state: ConsoleState | null): number | null {
function measuredLatency(state: XgridsK1State | null): number | null {
if (state?.source_mode !== "live") return null;
const metrics = state?.metrics;
if (!metrics) return null;
@@ -42,8 +42,8 @@ function measuredLatency(state: ConsoleState | null): number | null {
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
}
export function useK1Console() {
const [state, setState] = useState<ConsoleState | null>(null);
export function useXgridsK1Runtime(enabled: boolean) {
const [state, setState] = useState<XgridsK1State | null>(null);
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
@@ -51,15 +51,16 @@ export function useK1Console() {
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true);
const acceptState = useCallback((nextState: ConsoleState) => {
const acceptState = useCallback((nextState: XgridsK1State) => {
setState(nextState);
setBackendStatus("online");
}, []);
const refresh = useCallback(async (reportErrors = true) => {
if (!enabled) return;
const [healthResult, stateResult] = await Promise.allSettled([
api.getHealth(),
api.getState(),
xgridsK1Api.getHealth(),
xgridsK1Api.getState(),
]);
if (!mounted.current) return;
@@ -80,10 +81,11 @@ export function useK1Console() {
if (stateResult.status === "rejected" && reportErrors) {
setError(messageFor(stateResult.reason));
}
}, [acceptState]);
}, [acceptState, enabled]);
const run = useCallback(
async (action: PendingAction, operation: () => Promise<ConsoleState>) => {
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
if (!enabled) return false;
setPendingAction(action);
setError(null);
@@ -103,40 +105,51 @@ export function useK1Console() {
if (mounted.current) setPendingAction(null);
}
},
[acceptState],
[acceptState, enabled],
);
const scan = useCallback(
() => run("scan", () => api.scanBle({ duration_seconds: 6 })),
() => run("scan", () => xgridsK1Api.scanBle({ duration_seconds: 6 })),
[run],
);
const connect = useCallback(
(request: ConnectRequest) => run("connect", () => api.connect(request)),
(request: ConnectRequest) => run("connect", () => xgridsK1Api.connect(request)),
[run],
);
const startLive = useCallback(
(request: LiveRequest = {}) => run("live", () => api.startLive(request)),
(request: LiveRequest = {}) => run("live", () => xgridsK1Api.startLive(request)),
[run],
);
const startReplay = useCallback(
(request: ReplayRequest) => run("replay", () => api.startReplay(request)),
(request: ReplayRequest) => run("replay", () => xgridsK1Api.startReplay(request)),
[run],
);
const stop = useCallback(
() => run("stop", () => api.stopSession()),
() => run("stop", () => xgridsK1Api.stopSession()),
[run],
);
const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
[run],
);
useEffect(() => {
if (!enabled) {
mounted.current = true;
setState(null);
setBackendStatus("checking");
setEventStatus("closed");
setPendingAction(null);
setError(null);
setLatencyHistory([]);
return;
}
mounted.current = true;
void refresh(true);
const poll = window.setInterval(() => void refresh(false), 4_000);
@@ -145,9 +158,11 @@ export function useK1Console() {
mounted.current = false;
window.clearInterval(poll);
};
}, [refresh]);
}, [enabled, refresh]);
useEffect(() => {
if (!enabled) return;
let dispose: (() => void) | undefined;
let retry: number | undefined;
let cancelled = false;
@@ -173,7 +188,7 @@ export function useK1Console() {
if (retry !== undefined) window.clearTimeout(retry);
dispose?.();
};
}, [acceptState]);
}, [acceptState, enabled]);
useEffect(() => {
const latency = measuredLatency(state);
+5 -1
View File
@@ -6,6 +6,8 @@ import "@nodedc/tokens/themes.css";
import "@nodedc/ui-core/styles.css";
import App from "./App";
import { installedDevicePlugins } from "./composition/devicePlugins";
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
import "./styles.css";
const rootElement = document.getElementById("root");
@@ -20,6 +22,8 @@ applyNodedcTheme(rootElement, { theme: "dark" });
createRoot(rootElement).render(
<StrictMode>
<App />
<DevicePluginHostProvider plugins={installedDevicePlugins}>
<App />
</DevicePluginHostProvider>
</StrictMode>,
);
+23 -41
View File
@@ -1,39 +1,38 @@
import type { StatusTone } from "@nodedc/ui-react";
import type { K1Metrics } from "./api";
import type { BackendStatus } from "./useK1Console";
import type {
BackendStatus,
RuntimePhase,
SourceMode,
StreamMetrics,
} from "./core/runtime/contracts";
const phaseLabels: Record<string, string> = {
const phaseLabels: Record<RuntimePhase, string> = {
unconfigured: "Модель не выбрана",
idle: "Ожидание",
scanning: "Поиск Bluetooth",
device_selected: "Устройство выбрано",
provisioning: "Передача настроек Wi‑Fi",
connecting: "Подключение",
configuring: "Настройка устройства",
connected: "Устройство подключено",
starting_live: "Запуск потока",
live: "Поток в реальном времени",
replay: "Повтор записи",
starting: "Запуск потока",
streaming: "Поток в реальном времени",
replaying: "Повтор записи",
stopping: "Остановка",
error: "Ошибка",
};
export function phaseLabel(phase: string | null | undefined): string {
if (!phase) return "Нет состояния";
return phaseLabels[phase] ?? "Неизвестное состояние";
export function phaseLabel(phase: RuntimePhase | null | undefined): string {
return phase ? phaseLabels[phase] : "Нет состояния";
}
export function phaseTone(phase: string | null | undefined): StatusTone {
if (!phase) return "neutral";
export function phaseTone(phase: RuntimePhase | null | undefined): StatusTone {
if (!phase || phase === "unconfigured" || phase === "idle") return "neutral";
if (phase === "error") return "danger";
if (["connected", "live", "replay"].includes(phase)) return "success";
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) {
return "accent";
}
return "neutral";
if (["connected", "streaming", "replaying"].includes(phase)) return "success";
return "accent";
}
export function backendLabel(status: BackendStatus): string {
return {
unconfigured: "Модель не выбрана",
checking: "Проверка контура",
online: "Контур доступен",
degraded: "Контур ограничен",
@@ -44,33 +43,16 @@ export function backendLabel(status: BackendStatus): string {
export function backendTone(status: BackendStatus): StatusTone {
if (status === "online") return "success";
if (status === "degraded" || status === "checking") return "warning";
if (status === "unconfigured") return "neutral";
return "danger";
}
export function eventStatusLabel(status: string): string {
return {
connecting: "подключение",
open: "подключён",
closed: "закрыт",
error: "ошибка",
}[status] ?? "неизвестно";
}
export function finiteMetric(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function pipelineLatency(metrics: K1Metrics | undefined): number | null {
if (!metrics) return null;
const direct = finiteMetric(metrics.pipeline_ms ?? metrics.end_to_end_ms);
if (direct !== null) return direct;
const segments = [
finiteMetric(metrics.mqtt_to_decode_ms),
finiteMetric(metrics.publish_ms),
].filter((value): value is number => value !== null);
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
export function pipelineLatency(metrics: StreamMetrics | undefined): number | null {
return finiteMetric(metrics?.latencyMs);
}
export function formatNumber(value: number | null, digits = 1): string {
@@ -81,7 +63,7 @@ export function formatNumber(value: number | null, digits = 1): string {
});
}
export function sourceModeLabel(mode: string | null | undefined): string {
export function sourceModeLabel(mode: SourceMode | null | undefined): string {
if (mode === "live") return "Реальное время";
if (mode === "replay") return "Повтор записи";
if (mode === "idle") return "Ожидание";
+5 -5
View File
@@ -175,7 +175,7 @@ export const workspaces: WorkspaceDefinition[] = [
description: "От физического устройства до операторского интерфейса.",
capabilities: [
active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."),
active("Приём данных", "MQTT, декодирование облака точек и позы."),
active("Приём данных", "Плагинский транспорт, нормализация облака точек и позы."),
ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."),
contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."),
],
@@ -231,7 +231,7 @@ export const workspaces: WorkspaceDefinition[] = [
label: "Локальное устройство",
title: "Локальное устройство",
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
description: "Рабочий путь BLE → Wi‑Fi → поток для первого подключённого устройства.",
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
icon: "network",
kind: "device",
groups: [],
@@ -526,8 +526,8 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Артефакты",
description: "Сохраняется политика «сначала исходные данные».",
capabilities: [
active("Нативные захваты", "Сырые данные MQTT и обезличенный манифест."),
active("Воспроизведение", "Воспроизведение исходной записи MQTT и проверенного TSV."),
active("Нативные захваты", "Сырые конверты устройства и обезличенный манифест."),
active("Воспроизведение", "Повтор исходной записи через адаптер выбранной модели."),
ready("Запись RRD", "Совместимая запись Rerun после появления потокового адаптера."),
ready("Компоновка RBL", "Версионируемая компоновка визуализатора рядом с кодом."),
],
@@ -548,7 +548,7 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Транспорт",
description: "Источник отделён от визуального представления.",
capabilities: [
active("MQTT вход", "Доказанный локальный поток текущего устройства."),
active("Плагинский вход", "Локальный поток активной модели устройства."),
active("Управление REST / WebSocket", "Состояние и управляющие операции локального адаптера."),
ready("Rerun gRPC", "Нативный источник реального времени для встроенного веб-визуализатора."),
ready("RRD по HTTP", "Открытие одной или нескольких записей."),
+144
View File
@@ -1,3 +1,146 @@
.device-workspace--model-picker,
.device-plugin-slot {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.device-model-catalog {
background: var(--station-panel);
}
.device-model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 0.85rem;
margin-top: 1rem;
}
.device-model-card {
display: grid;
min-width: 0;
gap: 0.9rem;
border: 1px solid var(--station-hairline);
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.device-model-card > header {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
}
.device-model-card__mark {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border-radius: 0.7rem;
background: rgb(255 255 255 / 0.06);
color: var(--nodedc-text-primary);
}
.device-model-card header span,
.device-model-card header h3,
.device-model-card > p,
.device-model-card dl {
margin: 0;
}
.device-model-card header span {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.device-model-card header h3 {
margin-top: 0.12rem;
color: var(--nodedc-text-primary);
font-size: 0.82rem;
}
.device-model-card > p {
color: var(--nodedc-text-secondary);
font-size: 0.67rem;
line-height: 1.5;
}
.device-model-card dl {
display: grid;
gap: 0.42rem;
border-top: 1px solid var(--station-hairline);
border-bottom: 1px solid var(--station-hairline);
padding: 0.7rem 0;
}
.device-model-card dl > div {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.61rem;
}
.device-model-card dt {
color: var(--nodedc-text-muted);
}
.device-model-card dd {
margin: 0;
color: var(--nodedc-text-secondary);
text-align: right;
}
.device-model-card__capabilities {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
}
.device-model-card__capabilities span {
border-radius: 999px;
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
padding: 0.28rem 0.5rem;
font-size: 0.54rem;
}
.device-plugin-slot__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-radius: 0.9rem;
background: rgb(255 255 255 / 0.035);
padding: 0.72rem 0.85rem;
}
.device-plugin-slot__bar > div {
display: grid;
min-width: 0;
gap: 0.12rem;
}
.device-plugin-slot__bar strong {
color: var(--nodedc-text-primary);
font-size: 0.74rem;
}
.device-plugin-slot__bar small {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.device-plugin-slot__bar .device-plugin-slot__error {
color: rgb(var(--nodedc-danger-rgb));
}
.xgrids-k1-plugin {
.device-workspace__grid {
display: grid;
min-width: 0;
@@ -414,3 +557,4 @@
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
}
+10 -10
View File
@@ -3,7 +3,7 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.device-workspace__grid {
.xgrids-k1-plugin .device-workspace__grid {
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
}
@@ -13,7 +13,7 @@
}
@media (max-width: 1280px) {
.device-workspace__grid,
.xgrids-k1-plugin .device-workspace__grid,
.overview-grid,
.mission-layout {
grid-template-columns: 1fr;
@@ -72,7 +72,7 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.diagnostics-grid {
.xgrids-k1-plugin .diagnostics-grid {
grid-template-columns: 1fr;
}
@@ -210,32 +210,32 @@
display: none;
}
.session-form--replay {
.xgrids-k1-plugin .session-form--replay {
grid-template-columns: 1fr;
}
.session-form--replay > :first-child {
.xgrids-k1-plugin .session-form--replay > :first-child {
grid-column: auto;
}
.session-footer,
.error-banner,
.xgrids-k1-plugin .session-footer,
.xgrids-k1-plugin .error-banner,
.source-format-list > div {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.error-banner {
.xgrids-k1-plugin .error-banner {
grid-template-columns: auto minmax(0, 1fr);
}
.error-banner__actions {
.xgrids-k1-plugin .error-banner__actions {
grid-column: 2;
justify-content: flex-end;
}
.device-row__action {
.xgrids-k1-plugin .device-row__action {
align-items: stretch;
flex-direction: column;
}
@@ -1,556 +1,122 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Button,
Checker,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import type { BleDevice } from "../api";
import { MetricCard } from "../components/MetricCard";
import { localizeRuntimeMessage } from "../messages";
import {
backendLabel,
backendTone,
eventStatusLabel,
finiteMetric,
formatNumber,
phaseLabel,
phaseTone,
pipelineLatency,
sourceModeLabel,
} from "../presentation";
import type { useK1Console } from "../useK1Console";
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
import type { RegisteredDeviceModel } from "../core/device-plugins/contracts";
type ConsoleController = ReturnType<typeof useK1Console>;
type SessionIntent = "live" | "replay";
const sessionItems = [
{ value: "live", label: "Реальное устройство" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function WizardStep({
number,
title,
status,
tone = "neutral",
children,
}: {
number: string;
title: string;
status: string;
tone?: StatusTone;
children: ReactNode;
}) {
return (
<section className="wizard-step">
<div className="wizard-step__rail" aria-hidden="true">
<span>{number}</span>
</div>
<div className="wizard-step__content">
<header>
<h3>{title}</h3>
<StatusBadge tone={tone}>{status}</StatusBadge>
</header>
{children}
</div>
</section>
);
}
function DeviceRow({
device,
selected,
function ModelCard({
registered,
onSelect,
}: {
device: BleDevice;
selected: boolean;
registered: RegisteredDeviceModel;
onSelect: () => void;
}) {
const { model, plugin } = registered;
return (
<div
className="device-row"
data-compatible={device.likely_k1 ? "true" : undefined}
data-selected={selected ? "true" : undefined}
>
<div className="device-row__identity">
<span className="device-row__signal" aria-hidden="true" />
<div>
<span className="device-row__name">
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
</span>
<code>{device.device_id}</code>
<article className="device-model-card">
<header>
<div className="device-model-card__mark" aria-hidden="true">
<Icon name="network" />
</div>
<div>
<span>{model.vendor}</span>
<h3>{model.displayName}</h3>
</div>
<StatusBadge tone={model.verified ? "success" : "warning"}>
{model.verified ? "Проверено" : "Экспериментально"}
</StatusBadge>
</header>
<p>{model.description}</p>
<dl>
<div><dt>Категория</dt><dd>{model.category}</dd></div>
<div><dt>Плагин</dt><dd>{plugin.manifest.metadata.displayName} · v{plugin.manifest.metadata.version}</dd></div>
</dl>
<div className="device-model-card__capabilities" aria-label="Возможности модели">
{model.capabilities.map((capability) => <span key={capability.id}>{capability.label}</span>)}
</div>
<div className="device-row__action">
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
<Button
size="compact"
variant={selected ? "primary" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Выбрано" : "Выбрать"}
</Button>
</div>
</div>
<Button width="full" variant="primary" onClick={onSelect}>
Выбрать модель
</Button>
</article>
);
}
function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
{values.length ? (
values.map((value, index) => (
<span
key={`${index}-${value}`}
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
title={`${value.toFixed(1)} мс`}
/>
))
) : (
<p>Измерений пока нет. График появится после получения реальных данных.</p>
)}
</div>
);
}
export function DeviceWorkspace({
console,
onOpenSpatialScene,
}: {
console: ConsoleController;
onOpenSpatialScene: () => void;
}) {
export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () => void }) {
const {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh,
clearError,
scan,
connect,
startLive,
startReplay,
stop,
} = console;
registry,
selection,
selectionTransitionPending,
selectionTransitionError,
selectModel,
clearSelection,
} = useDevicePluginHost();
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
useEffect(() => {
if (state?.selected_device_id) {
setSelectedDeviceId(state.selected_device_id);
return;
}
if (
selectedDeviceId &&
state?.devices &&
!state.devices.some((device) => device.device_id === selectedDeviceId)
) {
setSelectedDeviceId("");
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
const sourceLabel = sourceModeLabel(state?.source_mode);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const submitConnect = async () => {
if (!canConnect) return;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
});
if (succeeded) setPassword("");
};
const submitLive = async () => {
const host = liveHost.trim();
const started = await startLive(host ? { host } : {});
if (started) onOpenSpatialScene();
};
const submitReplay = async () => {
const speed = Number(replaySpeed);
const started = await startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
if (started) onOpenSpatialScene();
};
return (
<div className="device-workspace">
{error ? (
<aside className="error-banner" role="alert">
<span className="error-banner__dot" aria-hidden="true" />
if (!selection) {
return (
<div className="device-workspace device-workspace--model-picker">
<section className="workspace-lead workspace-lead--compact">
<div>
<strong>Локальная операция завершилась ошибкой</strong>
<p>{localizeRuntimeMessage(error)}</p>
<span className="section-eyebrow">ЛОКАЛЬНОЕ УСТРОЙСТВО · ШАГ 01</span>
<h2>Выберите модель устройства</h2>
<p>
Mission Core покажет только сценарий выбранного плагина. До выбора модели нет
поиска оборудования, сетевых реквизитов, управления потоком или фиктивных метрик.
</p>
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
Повторить
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Закрыть
</Button>
<div className="workspace-lead__status">
<StatusBadge tone="neutral">Модель не выбрана</StatusBadge>
<span>{registry.models.length} моделей доступно в локальной сборке</span>
</div>
</aside>
) : null}
</section>
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
<h2>Подключение первого устройства</h2>
<p>
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
операторская модель от конкретного устройства не зависят.
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение адаптера"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Реальное число декодированных точек"
/>
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
/>
</section>
<div className="device-workspace__grid">
<GlassSurface className="connection-panel" padding="lg">
<GlassSurface className="device-model-catalog" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
<h2>Подключите устройство к сети</h2>
<span className="section-eyebrow">УСТАНОВЛЕННЫЕ ПЛАГИНЫ</span>
<h2>Парк поддерживаемых моделей</h2>
</div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<StatusBadge tone="neutral">{registry.plugins.length} плагин</StatusBadge>
</header>
<div className="wizard-list">
<WizardStep
number="01"
title="Включите устройство"
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
tone={powerConfirmed ? "success" : "warning"}
>
<div className="nodedc-field">
<span className="nodedc-field__description">
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
</span>
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
onChange={setPowerConfirmed}
/>
</div>
</WizardStep>
<WizardStep
number="02"
title="Выберите Bluetooth-устройство"
status={
pendingAction === "scan"
? "Поиск…"
: selectedDeviceId
? "Устройство выбрано"
: `Найдено: ${devices.length}`
}
tone={
pendingAction === "scan"
? "accent"
: selectedDeviceId
? "success"
: "neutral"
}
>
<p className="step-copy">
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
профиль — только подсказка; окончательный выбор всегда делает оператор.
</p>
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy}
onClick={() => {
setSelectedDeviceId("");
void scan();
}}
>
{pendingAction === "scan"
? "Сканируем Bluetooth — 6 секунд…"
: "Показать все BLE-устройства"}
</Button>
<div className="device-list">
{devices.length ? (
devices.map((device) => (
<DeviceRow
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => setSelectedDeviceId(device.device_id)}
/>
))
) : (
<div className="empty-device-list">
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
повторите поиск.
</div>
)}
</div>
</WizardStep>
<WizardStep
number="03"
title="Передайте настройки Wi‑Fi"
status={state?.k1_ip ? "Подключено" : "Не подключено"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Название сети Wi‑Fi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
/>
<TextField
label="Пароль Wi‑Fi"
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
placeholder="Введите пароль"
/>
</div>
<div className="connection-summary">
<span>Устройство</span>
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
</div>
<Button
width="full"
variant="primary"
icon={<Icon name="network" />}
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
</Button>
<p className="safety-note">
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
удаляется из формы после успешного подключения.
</p>
</WizardStep>
<div className="device-model-grid">
{registry.models.map((registered) => (
<ModelCard
key={registered.model.id}
registered={registered}
onSelect={() => void selectModel(registered.model.id)}
/>
))}
</div>
</GlassSurface>
<div className="device-workspace__side">
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">
{sessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
/>
{sessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
onClick={() => void submitLive()}
>
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
</p>
</div>
) : (
<div className="session-form session-form--replay">
<TextField
label="Путь к записи"
hint="Локальный файл исходных данных"
value={replayPath}
onChange={(event) => setReplayPath(event.target.value)}
spellCheck={false}
placeholder="sessions/.../capture.tsv"
/>
<TextField
label="Скорость повтора"
hint="Множитель"
type="number"
min="0.1"
step="0.1"
value={replaySpeed}
onChange={(event) => setReplaySpeed(event.target.value)}
/>
<div className="nodedc-field">
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
<Checker
checked={replayLoop}
label="Повторять по кругу"
onChange={setReplayLoop}
/>
</div>
<Button
variant="primary"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
</Button>
</div>
)}
<div className="session-footer">
<p>Статус изменится только после ответа локального сервиса.</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
</Button>
</div>
</GlassSurface>
<div className="diagnostics-grid">
<GlassSurface className="status-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
<h2>Локальный контур</h2>
</div>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</header>
<dl className="detail-list">
<DetailRow label="Канал событий">
<span className="inline-state" data-state={eventStatus}>
{eventStatusLabel(eventStatus)}
</span>
</DetailRow>
<DetailRow label="Источник">{sourceLabel}</DetailRow>
<DetailRow label="Адрес устройства">
<code>{state?.k1_ip || "Не получен"}</code>
</DetailRow>
</dl>
</GlassSurface>
<GlassSurface className="latency-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
<h2>Время до публикации</h2>
</div>
<strong className="latency-now">
{formatNumber(latency)} <span>мс</span>
</strong>
</header>
<LatencyTrace values={latencyHistory} />
<div className="latency-legend">
<span>Старые</span>
<span>Последние</span>
</div>
</GlassSurface>
</div>
</div>
</div>
);
}
const ConnectionView = selection.ConnectionView;
return (
<div className="device-plugin-slot">
<div className="device-plugin-slot__bar">
<div>
<span className="section-eyebrow">АКТИВНАЯ МОДЕЛЬ</span>
<strong>{selection.model.displayName}</strong>
<small>{selection.plugin.manifest.metadata.displayName} · v{selection.plugin.manifest.metadata.version}</small>
{selectionTransitionError ? (
<small className="device-plugin-slot__error" role="alert">
{selectionTransitionError}
</small>
) : null}
</div>
<Button
variant="secondary"
size="compact"
disabled={selectionTransitionPending}
onClick={() => void clearSelection()}
>
{selectionTransitionPending ? "Завершаем текущую сессию…" : "Выбрать другую модель"}
</Button>
</div>
<ConnectionView
model={selection.model}
host={{ openSpatialScene: onOpenSpatialScene }}
/>
</div>
);
}
@@ -6,9 +6,8 @@ import {
StatusBadge,
} from "@nodedc/ui-react";
import type { ConsoleState } from "../api";
import { MetricCard } from "../components/MetricCard";
import { localizeRuntimeMessage } from "../messages";
import type { BackendStatus, MissionRuntimeState } from "../core/runtime/contracts";
import {
RerunViewport,
type RerunSelection,
@@ -21,7 +20,6 @@ import {
} from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings";
import type { BackendStatus } from "../useK1Console";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
@@ -82,7 +80,7 @@ export interface WorkspaceNavigation {
export interface WorkspaceRendererProps {
definition: WorkspaceDefinition;
state: ConsoleState | null;
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
sourceUrl: string;
sceneSettings: SceneSettings;
@@ -95,13 +93,13 @@ function OverviewWorkspace({
backendStatus,
navigation,
}: WorkspaceRendererProps) {
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount);
const adapterOnline = backendStatus === "online";
const rerunReady = Boolean(state?.rerun_grpc_url);
const rerunReady = Boolean(state?.spatialSource?.url);
return (
<div className="standard-workspace overview-workspace">
@@ -115,7 +113,7 @@ function OverviewWorkspace({
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
detail="Вход адаптера → Scene Sink; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА"
@@ -130,7 +128,7 @@ function OverviewWorkspace({
/>
<MetricCard
eyebrow="РЕЖИМ"
value={sourceModeLabel(state?.source_mode)}
value={sourceModeLabel(state?.sourceMode)}
detail="Реальное время, повтор или ожидание"
/>
</section>
@@ -147,16 +145,16 @@ function OverviewWorkspace({
</StatusBadge>
</header>
<div className="pipeline-strip" aria-label="Путь данных">
<div data-state={state?.k1_ip ? "ready" : "idle"}>
<div data-state={state?.activeDevice ? "ready" : "idle"}>
<span>01</span>
<strong>Устройство</strong>
<small>{state?.k1_ip || "не назначено"}</small>
<small>{state?.activeDevice?.endpointLabel || state?.activeDevice?.displayName || "не назначено"}</small>
</div>
<Icon name="chevron-right" />
<div data-state={streamActive ? "ready" : "idle"}>
<span>02</span>
<strong>Адаптер</strong>
<small>{sourceModeLabel(state?.source_mode)}</small>
<small>{sourceModeLabel(state?.sourceMode)}</small>
</div>
<Icon name="chevron-right" />
<div data-state={rerunReady ? "ready" : "idle"}>
@@ -185,7 +183,7 @@ function OverviewWorkspace({
<span><Icon name="network" /></span>
<div>
<strong>Подключить устройство</strong>
<small>Bluetooth, Wi‑Fi и запуск потока</small>
<small>Сценарий установленного плагина и запуск потока</small>
</div>
<Icon name="chevron-right" />
</button>
@@ -243,11 +241,11 @@ function SpatialWorkspace({
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
@@ -315,11 +313,11 @@ function SpatialWorkspace({
</div>
</div>
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? (
{state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически.
</span>
</div>
@@ -440,11 +438,11 @@ function MapWorkspace({ definition }: WorkspaceRendererProps) {
function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
const rows = useMemo(() => [
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.source_mode) : "нет событий", active: Boolean(state?.phase) },
{ label: "Поток точек", value: state?.source_mode && state.source_mode !== "idle" ? "активен" : "ожидание", active: state?.source_mode !== "idle" && Boolean(state?.source_mode) },
{ label: "Поза", value: state?.metrics?.point_count ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.point_count) },
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.sourceMode) : "нет событий", active: Boolean(state?.phase) },
{ label: "Поток точек", value: state?.sourceMode && state.sourceMode !== "idle" ? "активен" : "ожидание", active: state?.sourceMode !== "idle" && Boolean(state?.sourceMode) },
{ label: "Поза", value: state?.metrics?.pointCount ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.pointCount) },
{ label: "Камеры", value: "каналы не назначены", active: false },
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) },
{ label: "События", value: state?.message || "нет событий", active: Boolean(state?.message) },
], [state]);
return (