feat: introduce device plugin runtime boundary
This commit is contained in:
@@ -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 и чувствительные данные
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+39
-35
@@ -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;
|
||||
}
|
||||
+36
-21
@@ -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);
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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 "Ожидание";
|
||||
|
||||
@@ -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", "Открытие одной или нескольких записей."),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user