feat: build NODE.DC control station shell

This commit is contained in:
DCCONSTRUCTIONS 2026-07-16 01:20:09 +03:00
parent 8b5b90d8af
commit 6402601d6d
30 changed files with 5296 additions and 1792 deletions

View File

@ -55,45 +55,57 @@ uv run k1link doctor
uv run pytest
```
## Live console and Foxglove
## NODE.DC Control Station and visualization adapters
Build the browser control surface once, then launch the whole local stand from
the repository root:
The browser application is now a universal NODE.DC Control Station rather than
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter.
Install, type-check, build and serve the complete local application from the
repository root:
```bash
uv sync --group dev
cd apps/k1-viewer
npm install
npm run typecheck
npm run build
cd ../..
uv run k1link serve
```
Open `http://127.0.0.1:8000`. The API, credential form and Foxglove WebSocket
bind to loopback only. The console supports:
Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
plane and credential endpoint bind to loopback only. The current K1 adapter
still provides real CoreBluetooth discovery, one operator-triggered reviewed
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
reviewed-TSV replay, raw-first evidence storage and measured preview metrics.
Physical K1 scanning is still started and stopped by the verified double-click;
the connector publishes no modeling command.
- real CoreBluetooth K1 discovery;
- one operator-triggered reviewed BLE Wi-Fi provisioning write;
- live read-only MQTT capture with raw-first evidence storage;
- replay of native `.k1mqtt` evidence and the reviewed four-column TSV capture;
- `/k1/points`, `/k1/pose`, `/k1/trajectory` and `/k1/metrics` for Foxglove;
- measured Mac-side MQTT-receive-to-Foxglove-publish latency and bounded
latest-wins preview dropping.
The Observation spatial workspace embeds the open-source Rerun Web Viewer
inside the NODE.DC shell. It can open a compatible RRD file over HTTP(S) or a
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
use an external hosted viewer UI.
For live mode, start the session in the console and use the verified physical
double-click on K1 to start or stop scanning. The connector deliberately does
not publish modeling commands yet. For the recorded proof, replay this ignored
local file at `1x` with loop enabled:
There is one important current boundary: the automatic K1 MQTT → Rerun
RRD/gRPC contract is **not implemented yet**. Starting a K1 live or replay
session updates real state and metrics, but it does not by itself populate the
embedded Rerun viewport. The scene controls for point size, gradients,
accumulation, layers, timeline and layout are presently a product UI contract;
their Rerun Blueprint/playback adapter is also still pending. No synthetic
point cloud, trajectory, camera frame or latency value is generated to conceal
these missing links.
```text
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
```
Foxglove remains the full 3D workspace rather than being reimplemented in the
React console. In its 3D panel, select `/k1/points`, color by `intensity` (or
`z`/`<distance>`) and choose Turbo, Rainbow or a custom gradient. Add
`/k1/trajectory` for the cyan path and `/k1/pose` for the current pose. See the
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) for timing semantics and current
limitations.
The existing Python MQTT → Foxglove pipeline remains in place as the verified
legacy visualization adapter. It still publishes `/k1/points`, `/k1/pose`,
`/k1/trajectory` and `/k1/metrics`, and preserves the measured
MQTT-receive-to-Foxglove-publish latency plus bounded latest-wins preview
dropping. It is retained for evidence, regression and diagnostics, not as the
target Control Station interface. The historical
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records that proven path and its
timing semantics; the current frontend contract is documented in
[`apps/k1-viewer/README.md`](apps/k1-viewer/README.md).
`doctor` is intentionally non-invasive. It checks the local Python environment
and reports external tools; it does not request Bluetooth permission, scan the

View File

@ -1,51 +1,224 @@
# K1 Live Console
# Пункт управления NODE.DC
React 19 + TypeScript + Vite frontend for the local K1 bridge. The console handles
connection setup, source selection, transport status, latency metrics and handoff to
the Foxglove 3D viewer. It does not emulate scanner operations or generate placeholder
telemetry.
Универсальный браузерный пункт управления NODE.DC на React 19, TypeScript и
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
device adapter, но структура интерфейса от него не зависит.
## Run locally
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
переход во внешний облачный интерфейс. Сам браузерный компонент, однако, должен
получить совместимый источник RRD или Rerun gRPC.
## Текущее состояние
| Контур | Состояние | Что это означает |
| --- | --- | --- |
| NODE.DC fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
| Legacy Foxglove adapter | Реализован | Текущий Python runtime публикует декодированные K1 данные в локальный Foxglove WebSocket. Это проверенный диагностический backend, а не продуктовый UI. |
| Встроенный Rerun Viewer | Реализован | Открывает назначенный RRD по HTTP(S) или совместимый Rerun gRPC/proxy source внутри Control Station. |
| Автоматический MQTT → Rerun | **Не реализован** | Запуск K1 live/replay пока не создаёт RRD и не подаёт данные во встроенный Rerun viewport. Нужен отдельный stream adapter. |
| Контролы сцены → Rerun Blueprint | **Не реализованы** | Размер точек, палитра, накопление, слои, проекция, timeline и компоновка пока являются честным UI-контрактом и состоянием текущего сеанса. |
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
Приложение не генерирует демонстрационное облако, траекторию, кадры или
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
показывают `—`.
## Архитектура данных
```text
NODE.DC Control Station в браузере
├── control plane
│ └── REST /api/* + WebSocket /api/events
│ └── FastAPI на 127.0.0.1:8000
│ ├── CoreBluetooth: discovery и reviewed Wi-Fi provisioning
│ └── live/replay session runtime
├── текущий K1 preview path — legacy adapter
│ └── K1 MQTT :1883, read-only
│ ├── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
│ └── protobuf/LZ4 decoder
│ └── Foxglove bridge → локальный WebSocket
└── продуктовая пространственная сцена
└── встроенный @rerun-io/web-viewer
└── RRD URL или Rerun gRPC/proxy source
```
Между декодером K1 и Rerun Viewer сейчас нет автоматической стрелки. Поля
`foxglove_ws_url` и `foxglove_viewer_url` ещё присутствуют в API только потому,
что Python runtime продолжает поднимать проверенный legacy adapter. Пункт
управления не использует их как основную поверхность визуализации.
## Фиксированная оболочка
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
всех функциональных модулей:
1. `AppHeader` — марка NODE.DC, выбор архитектурного раздела и состояние
локального backend.
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
выбранного раздела.
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
раскрытия внутренних панелей визуального движка.
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
Control Station. Это ещё не означает, что все продуктовые контролы уже связаны
с API Rerun: текущая граница явно показана статусом `Интерфейс готов`.
## Архитектурные разделы
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
страницы.
| Раздел | Назначение |
| --- | --- |
| Центр | Оперативный обзор, состояние контура и активность оператора. |
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
как утверждение, что для каждой карточки уже существует backend.
## Установка, проверка, сборка и запуск
Требуются Node.js 20 или новее, `uv` и соседний checkout
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
Канонический путь от корня репозитория:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
uv sync --group dev
cd apps/k1-viewer
npm install
npm run typecheck
npm run build
cd ../..
uv run k1link serve
```
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
Wi-Fi.
Для разработки интерфейса после запуска backend:
```bash
cd apps/k1-viewer
npm install
npm run dev
```
The development server listens on `http://127.0.0.1:5173` and proxies `/api` to
`http://127.0.0.1:8000`. Set `VITE_API_TARGET` in a local `.env` file to use a
different backend address. Production artifacts are built with `npm run build`.
Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
`VITE_API_TARGET`. Preview production-сборки запускается командой
`npm run preview` на `http://127.0.0.1:4173`.
The NODE.DC UI packages are consumed from the sibling `NODEDC_DESIGN_GUIDELINE`
checkout through `file:` dependencies. Their source is not copied into this app.
## Операторский путь для текущего K1 adapter
## Backend contract
1. Запустить `uv run k1link serve` и открыть Control Station.
2. Выбрать **Парк → Локальное устройство**.
3. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
4. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
оператор.
5. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
reviewed provisioning-запись без автоматических повторов.
6. Запустить live-приём по определённому адресу K1 либо replay локального
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
останавливается подтверждённым двойным нажатием кнопки устройства.
7. Состояние, частота, число точек, задержка и пропуски preview появятся только
после прихода реальных данных.
| Method | Route | Body / purpose |
Этот путь запускает существующий MQTT/Foxglove runtime. Чтобы увидеть тот же
живой K1 поток во встроенной пространственной сцене, нужно сначала реализовать и
запустить MQTT→Rerun stream adapter; одного запуска live-сессии сейчас
недостаточно.
## Источники Rerun
В **Наблюдение → Пространственная сцена → Источник** принимается URL, который
понимает `@rerun-io/web-viewer` версии, зафиксированной в `package.json`:
```text
rerun+http://127.0.0.1:9876/proxy
http://127.0.0.1:8080/recording.rrd
https://example.internal/recording.rrd
```
- `rerun+http://…/proxy` — живой Rerun gRPC source через доступный браузеру
proxy;
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
- версия RRD должна быть совместима с версией Web Viewer;
- URL хранится только в состоянии текущей страницы;
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
оболочку. Продуктовые настройки отображения, custom timeline, переключатель
2D/3D/карты и сохранение RBL пока не вызывают Blueprint/playback API. Черновик
компоновки фиксируется только в памяти текущей страницы и не записывается на
диск.
## Контракт локального API
| Метод | Route | Тело / назначение |
| --- | --- | --- |
| `GET` | `/api/health` | Local service health |
| `GET` | `/api/state` | Authoritative console state |
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }` |
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }` |
| `POST` | `/api/session/live` | Optional `{ "host", "duration_seconds" }` |
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }` |
| `POST` | `/api/session/stop` | Stop the active source |
| `WS` | `/api/events` | State snapshots for live UI updates |
| `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` | Остановка активного источника. |
| `WS` | `/api/events` | Периодические snapshots для live UI. |
`/api/state` and state-changing responses may return the snapshot directly or as
`{ "state": { ... } }`. A snapshot exposes `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `foxglove_ws_url`, `foxglove_viewer_url`,
`source_mode` and `metrics`.
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `source_mode`, `metrics` и legacy-поля
`foxglove_ws_url`/`foxglove_viewer_url`. OpenAPI доступен по `/api/docs`.
The Wi-Fi password remains only in React memory, is sent in the JSON POST body, and is
cleared after the backend acknowledges a successful connection. It is never written to
local storage or included in a URL.
## Карта исходников frontend
## Explicit unavailable states
| Файл | Ответственность |
| --- | --- |
| `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/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/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
- Missing metrics render as an em dash; no synthetic values are used.
- BLE scanning remains a real API action and reports HTTP/network errors visibly.
- The Foxglove button is disabled until the backend supplies
`foxglove_viewer_url`.
- Replay cannot start without a backend-local capture path.
## Safety и чувствительные данные
- Control Station и credential endpoint доступны только на loopback.
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
очищается после успешного ответа и не сохраняется в URL/local storage.
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
случайные GATT writes и автоматические повторы запрещены.
- MQTT live/replay не публикует команды устройству. Запуск и остановка
физического сканирования остаются за кнопкой K1.
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
перегрузке preview может быть отброшен, raw evidence сохраняется.
- `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
траекторию и карту помещения. В репозиторий попадают только redacted manifests
и безопасная документация.

View File

@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Локальная консоль подключения XGRIDS Lixel K1 и просмотра облака точек в реальном времени."
content="Пункт управления NODE.DC — наблюдение, данные и планирование миссий беспилотных аппаратов."
/>
<meta name="theme-color" content="#08090b" />
<title>K1 · Облако точек в реальном времени · NODE.DC</title>
<title>NODE.DC · Пункт управления</title>
</head>
<body>
<div id="root"></div>

View File

@ -11,6 +11,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
@ -20,7 +21,8 @@
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0"
"vite": "^7.0.0",
"vite-plugin-wasm": "^3.6.0"
},
"engines": {
"node": ">=20"
@ -844,6 +846,12 @@
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"link": true
},
"node_modules/@rerun-io/web-viewer": {
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.34.1.tgz",
"integrity": "sha512-2Oq9Mw3qOs765XArGq4e/0pAIksPFKlAqkgo+PDsRkPd77/KdnQhb835suRmYQ6tuqlbPeVCFhlNIXT0+TjmTg==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1880,6 +1888,16 @@
}
}
},
"node_modules/vite-plugin-wasm": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz",
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -13,6 +13,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
@ -22,7 +23,8 @@
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0"
"vite": "^7.0.0",
"vite-plugin-wasm": "^3.6.0"
},
"engines": {
"node": ">=20"

View File

@ -0,0 +1,8 @@
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55">
<defs>
<style>.cls-1{fill:#e2e1e1}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:.75px}</style>
</defs>
<path class="cls-1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
<polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
<path class="cls-2" d="M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15ZM140.43 18.64c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09ZM188.88 18.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75ZM205.15 9.75c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,4 @@
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55">
<path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
<polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

File diff suppressed because it is too large Load Diff

View File

@ -99,7 +99,7 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
},
});
} catch {
throw new ApiError("Не удалось подключиться к локальному API K1.");
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
}
const bodyText = await response.text();
@ -119,9 +119,9 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
? body.detail
: typeof body === "string" && body.trim()
? body.trim()
: `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`;
: `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
throw new ApiError(
detail || `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`,
detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
response.status,
);
}
@ -203,5 +203,5 @@ export function openEventSocket(
socket.addEventListener("error", () => onStatus("error"));
socket.addEventListener("close", () => onStatus("closed"));
return () => socket.close(1000, "Консоль K1 закрыта");
return () => socket.close(1000, "Пункт управления закрыт");
}

View File

@ -0,0 +1,74 @@
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
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;
message?: string | null;
onOpenObservation: () => void;
onOpenDevice: () => void;
}
export function LandingStage({
root,
backendStatus,
phase,
message,
onOpenObservation,
onOpenDevice,
}: LandingStageProps) {
return (
<section className="landing-stage" data-root={root?.id ?? "home"}>
<div className="landing-stage__field" aria-hidden="true">
<span className="landing-stage__grid" />
<span className="landing-stage__orbit landing-stage__orbit--one" />
<span className="landing-stage__orbit landing-stage__orbit--two" />
<span className="landing-stage__orbit landing-stage__orbit--three" />
<span className="landing-stage__sweep" />
<span className="landing-stage__node landing-stage__node--one" />
<span className="landing-stage__node landing-stage__node--two" />
<span className="landing-stage__node landing-stage__node--three" />
<span className="landing-stage__node landing-stage__node--four" />
</div>
<div className="landing-stage__shade" />
<div className="landing-stage__copy">
<span className="section-eyebrow">{root?.eyebrow ?? "NODE.DC / ПУНКТ УПРАВЛЕНИЯ"}</span>
<h1>{root?.title ?? "Пункт управления"}</h1>
<p>
{root?.statement ??
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
</p>
<div className="landing-stage__actions">
<Button variant="accent" icon={<Icon name="globe" />} onClick={onOpenObservation}>
Пространственная сцена
</Button>
<Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}>
Локальное устройство
</Button>
</div>
</div>
<div className="landing-stage__status">
<div>
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</div>
<div>
<span className="section-eyebrow">ИСТОЧНИК</span>
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
</div>
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
</div>
<footer className="landing-stage__footer">
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
</footer>
</section>
);
}

View File

@ -0,0 +1,22 @@
import { GlassSurface } from "@nodedc/ui-react";
export interface MetricCardProps {
eyebrow: string;
value: string;
unit?: string;
detail: string;
featured?: boolean;
}
export function MetricCard({ eyebrow, value, unit, detail, featured = false }: MetricCardProps) {
return (
<GlassSurface className="metric-card" padding="md" data-featured={featured ? "true" : undefined}>
<span className="metric-card__eyebrow">{eyebrow}</span>
<div className="metric-card__reading">
<strong>{value}</strong>
{unit ? <span>{unit}</span> : null}
</div>
<p>{detail}</p>
</GlassSurface>
);
}

View File

@ -0,0 +1,126 @@
import { useEffect, useRef, useState } from "react";
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
export interface RerunSelection {
entityPath: string;
viewName?: string;
position?: [number, number, number];
}
export interface RerunViewportProps {
sourceUrl: string;
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
onSelectionChange?: (selection: RerunSelection | null) => void;
}
export function RerunViewport({
sourceUrl,
onStatusChange,
onSelectionChange,
}: RerunViewportProps) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
useEffect(() => {
const normalizedSource = sourceUrl.trim();
if (!normalizedSource || !hostRef.current) {
setStatus("idle");
onStatusChange?.("idle");
onSelectionChange?.(null);
return;
}
let disposed = false;
let started = false;
let stopViewer: (() => void) | undefined;
const unsubscribers: Array<() => void> = [];
setStatus("loading");
onStatusChange?.("loading");
void import("@rerun-io/web-viewer")
.then(async ({ WebViewer }) => {
const viewer = new WebViewer();
stopViewer = () => viewer.stop();
await viewer.start(
normalizedSource,
hostRef.current,
{
width: "100%",
height: "100%",
theme: "dark",
hide_welcome_screen: true,
enable_history: false,
allow_fullscreen: false,
},
null,
);
started = true;
if (disposed) {
viewer.stop();
return;
}
viewer.override_panel_state("top", "hidden");
viewer.override_panel_state("blueprint", "hidden");
viewer.override_panel_state("selection", "hidden");
viewer.override_panel_state("time", "hidden");
unsubscribers.push(
viewer.on("selection_change", (event) => {
const entity = event.items.find((item) => item.type === "entity");
if (!entity || entity.type !== "entity") {
onSelectionChange?.(null);
return;
}
onSelectionChange?.({
entityPath: entity.entity_path,
viewName: entity.view_name,
position: entity.position,
});
}),
);
setStatus("ready");
onStatusChange?.("ready");
})
.catch((error: unknown) => {
if (disposed) return;
const message = error instanceof Error ? error.message : "Не удалось запустить визуальный движок.";
setStatus("error");
onStatusChange?.("error", message);
});
return () => {
disposed = true;
unsubscribers.forEach((unsubscribe) => unsubscribe());
if (started) stopViewer?.();
onSelectionChange?.(null);
};
}, [onSelectionChange, onStatusChange, sourceUrl]);
return (
<div className="rerun-viewport" data-status={status}>
<div ref={hostRef} className="rerun-viewport__canvas" />
{status === "loading" ? (
<div className="rerun-viewport__notice" role="status">
<span className="busy-indicator" aria-hidden="true" />
<div>
<strong>Подключаем визуальный источник</strong>
<span>Запуск встроенного визуализатора Rerun.</span>
</div>
</div>
) : null}
{status === "error" ? (
<div className="rerun-viewport__notice rerun-viewport__notice--error" role="alert">
<div>
<strong>Источник не открыт</strong>
<span>Проверьте адрес RRD или Rerun gRPC в настройках источника.</span>
</div>
</div>
) : null}
</div>
);
}

View File

@ -11,7 +11,7 @@ import "./styles.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("K1 console root element is missing.");
throw new Error("Не найден корневой элемент пункта управления.");
}
rootElement.classList.add("nodedc-ui-root");

View File

@ -0,0 +1,20 @@
const runtimeMessageReplacements: Array<[RegExp, string]> = [
[/broker connection ended/gi, "соединение с брокером завершено"],
[/Unspecified error/gi, "неуказанная ошибка"],
[
/Device was not rediscovered; keep the K1 powered and nearby\.?/gi,
"Устройство не обнаружено повторно; оставьте его включённым и рядом.",
],
[/Foxglove/gi, "локальный мост визуализации"],
[/MacBook/gi, "компьютер"],
[/\bK1\b/g, "устройство"],
];
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
if (!message) return null;
return runtimeMessageReplacements.reduce(
(localized, [pattern, replacement]) => localized.replace(pattern, replacement),
message,
);
}

View File

@ -0,0 +1,90 @@
import type { StatusTone } from "@nodedc/ui-react";
import type { K1Metrics } from "./api";
import type { BackendStatus } from "./useK1Console";
const phaseLabels: Record<string, string> = {
idle: "Ожидание",
scanning: "Поиск Bluetooth",
device_selected: "Устройство выбрано",
provisioning: "Передача настроек WiFi",
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 {
checking: "Проверка контура",
online: "Контур доступен",
degraded: "Контур ограничен",
offline: "Контур недоступен",
}[status];
}
export function backendTone(status: BackendStatus): StatusTone {
if (status === "online") return "success";
if (status === "degraded" || status === "checking") return "warning";
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.decode_ms),
finiteMetric(metrics.publish_ms),
].filter((value): value is number => value !== null);
return segments.length ? 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 "Неизвестно";
}

View File

@ -0,0 +1,758 @@
import type { IconName } from "@nodedc/ui-react";
export type RootId = "center" | "fleet" | "observation" | "missions" | "data" | "system";
export type WorkspaceKind =
| "overview"
| "device"
| "spatial"
| "cameras"
| "map"
| "timeline"
| "missions"
| "catalog";
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
export interface Capability {
label: string;
description: string;
status: CapabilityStatus;
}
export interface CapabilityGroup {
title: string;
description: string;
capabilities: Capability[];
}
export interface WorkspaceDefinition {
id: string;
root: RootId;
label: string;
title: string;
eyebrow: string;
description: string;
icon: IconName;
kind: WorkspaceKind;
groups: CapabilityGroup[];
}
export interface RootDefinition {
id: RootId;
label: string;
title: string;
eyebrow: string;
description: string;
statement: string;
accent: string;
}
const active = (label: string, description: string): Capability => ({
label,
description,
status: "active",
});
const ready = (label: string, description: string): Capability => ({
label,
description,
status: "ready",
});
const contract = (label: string, description: string): Capability => ({
label,
description,
status: "contract",
});
const later = (label: string, description: string): Capability => ({
label,
description,
status: "later",
});
export const roots: RootDefinition[] = [
{
id: "center",
label: "Центр",
title: "Центр управления",
eyebrow: "ПУНКТ УПРАВЛЕНИЯ",
description: "Единая оперативная картина, состояние контура и события оператора.",
statement: "Наблюдать весь контур, быстро видеть отклонения и переходить к действию.",
accent: "ОПЕРАТИВНЫЙ КОНТУР",
},
{
id: "fleet",
label: "Парк",
title: "Аппараты и устройства",
eyebrow: "ПАРК / УСТРОЙСТВА",
description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.",
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
},
{
id: "observation",
label: "Наблюдение",
title: "Наблюдение",
eyebrow: "СИТУАЦИОННАЯ ОСВЕДОМЛЁННОСТЬ",
description: "Пространственная сцена, камеры, карты, объекты, телеметрия и время.",
statement: "Свести все сенсоры в синхронную и управляемую операторскую картину.",
accent: "ЗРЕНИЕ И ПРОСТРАНСТВО",
},
{
id: "missions",
label: "Миссии",
title: "Миссии",
eyebrow: "УПРАВЛЕНИЕ МИССИЯМИ",
description: "Планирование, маршруты, сценарии и наблюдение за исполнением.",
statement: "Собрать задачу из точек, ограничений и действий до передачи на борт.",
accent: "ПЛАНИРОВАНИЕ И ИСПОЛНЕНИЕ",
},
{
id: "data",
label: "Данные",
title: "Данные и записи",
eyebrow: "РАБОЧАЯ ОБЛАСТЬ ДАННЫХ",
description: "Сессии, потоки, сущности, воспроизведение и экспорт доказательств.",
statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.",
accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА",
},
{
id: "system",
label: "Система",
title: "Система",
eyebrow: "УПРАВЛЕНИЕ ПЛАТФОРМОЙ",
description: "Модули, интеграции, сеть, аудит и параметры операторского пункта.",
statement: "Подключать новые возможности модульно, не связывая интерфейс с одним устройством.",
accent: "МОДУЛИ И ИНТЕГРАЦИИ",
},
];
export const workspaces: WorkspaceDefinition[] = [
{
id: "command-overview",
root: "center",
label: "Оперативный обзор",
title: "Оперативный обзор",
eyebrow: "ЦЕНТР / СЕЙЧАС",
description: "Живое состояние локального контура и быстрые переходы к рабочим поверхностям.",
icon: "activity",
kind: "overview",
groups: [
{
title: "Оперативная картина",
description: "Состояние, которое уже приходит из локального адаптера.",
capabilities: [
active("Состояние устройства", "Фаза подключения, адрес и активный режим источника."),
active("Метрики потока", "Частота кадров, точки в кадре, задержка и пропуски предпросмотра."),
ready("Единый индикатор готовности", "Нормализованная готовность сенсоров и транспорта."),
],
},
{
title: "Будущий контур",
description: "Сводка нескольких аппаратов без изменения оболочки.",
capabilities: [
contract("Парк на карте", "Положение, связь и назначенная миссия каждого аппарата."),
contract("Очередь тревог", "Отклонения, потери связи и запросы оператора."),
later("Командные роли", "Разделение наблюдения, планирования и подтверждения действий."),
],
},
],
},
{
id: "contour-health",
root: "center",
label: "Состояние контура",
title: "Состояние контура",
eyebrow: "ЦЕНТР / СОСТОЯНИЕ",
description: "Сенсоры, транспорт, вычисления и внешние сервисы в одном диагностическом дереве.",
icon: "shield",
kind: "catalog",
groups: [
{
title: "Критический путь",
description: "От физического устройства до операторского интерфейса.",
capabilities: [
active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."),
active("Приём данных", "MQTT, декодирование облака точек и позы."),
ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."),
contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."),
],
},
],
},
{
id: "operator-activity",
root: "center",
label: "Активность",
title: "Активность оператора",
eyebrow: "ЦЕНТР / АКТИВНОСТЬ",
description: "Хронология подключений, запусков, остановок и изменений конфигурации.",
icon: "list",
kind: "timeline",
groups: [
{
title: "События",
description: "Операции интерфейса и ответы локального контура.",
capabilities: [
active("Текущие статусы", "REST и WebSocket уже передают фазу и текст операции."),
contract("Журнал действий", "Нормализованные события с пользователем, временем и результатом."),
later("Подпись подтверждений", "Аудит опасных команд и изменение полномочий."),
],
},
],
},
{
id: "vehicles",
root: "fleet",
label: "Аппараты",
title: "Реестр аппаратов",
eyebrow: "ПАРК / РЕЕСТР",
description: "Нейтральный реестр наземных, воздушных и стационарных платформ.",
icon: "apps",
kind: "catalog",
groups: [
{
title: "Идентичность аппарата",
description: "Никакой привязки продуктовой модели к конкретному производителю.",
capabilities: [
ready("Локальный стенд", "Первый аппарат представлен текущим устройством и его адаптером."),
contract("Паспорт борта", "Тип, серийный профиль, вычислитель, питание и транспорт."),
contract("Состояние доступности", "Онлайн, занят, обслуживание, потеря связи."),
later("Группы и рои", "Логические группы, роли и совместное назначение миссий."),
],
},
],
},
{
id: "local-device",
root: "fleet",
label: "Локальное устройство",
title: "Локальное устройство",
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
description: "Рабочий путь BLE → WiFi → поток для первого подключённого устройства.",
icon: "network",
kind: "device",
groups: [],
},
{
id: "payload",
root: "fleet",
label: "Сенсоры и каналы",
title: "Сенсоры и каналы",
eyebrow: "ПАРК / ПОЛЕЗНАЯ НАГРУЗКА",
description: "Полезная нагрузка аппарата и каналы данных, которые она публикует.",
icon: "grid",
kind: "catalog",
groups: [
{
title: "Пространственные сенсоры",
description: "Единый контракт для разных источников.",
capabilities: [
active("Облако точек", "Доказанный декодированный поток точек текущего устройства."),
active("Поза и траектория", "Доказанный поток позы и накопление пути."),
contract("Камеры", "Изображение, видео, глубина, внутренние и внешние параметры."),
contract("Навигация", "IMU, GNSS/RTK, одометрия и система координат."),
],
},
],
},
{
id: "configurations",
root: "fleet",
label: "Компоновки",
title: "Компоновки борта",
eyebrow: "ПАРК / КОМПОНОВКИ",
description: "Версионируемые сочетания сенсоров, вычислителей, транспорта и питания.",
icon: "settings",
kind: "catalog",
groups: [
{
title: "Шаблон компоновки",
description: "Повторяемая конфигурация для одинаковых аппаратов.",
capabilities: [
contract("Состав модулей", "Сенсоры, адаптеры, обработчики и зависимости."),
contract("Калибровки", "Системы координат, преобразования, внутренние и внешние параметры."),
contract("Профиль сети", "Локальная сеть, ячеистая сеть, LTE и серверный маршрут."),
later("Развёртывание на борт", "Пакет конфигурации и контролируемое обновление."),
],
},
],
},
{
id: "spatial-scene",
root: "observation",
label: "Пространственная сцена",
title: "Пространственная сцена",
eyebrow: "НАБЛЮДЕНИЕ / ЭФИР",
description: "Облако точек, траектория, преобразования и пространственные слои в единой 3D-сцене.",
icon: "globe",
kind: "spatial",
groups: [
{
title: "Базовая геометрия",
description: "Главный контур реального времени текущего этапа.",
capabilities: [
ready("Облако точек (Points3D)", "Точки, радиус, интенсивность, градиент и накопление."),
ready("Траектории (LineStrips3D)", "Траектория аппарата, маршруты и границы."),
ready("Преобразования (Transform3D)", "Дерево координат, поза и области обзора камер."),
contract("Поверхности и глубина", "Сетки, карты глубины и восстановленная геометрия."),
],
},
{
title: "Управление сценой",
description: "Функции движка перераспределены в интерфейс пункта управления.",
capabilities: [
contract("Выбор сущности", "Выбор объекта в сцене и синхронный инспектор."),
contract("Переопределения", "Видимость, цвет, радиус, подписи и порядок отрисовки."),
contract("Компоновка (Blueprint)", "Сохранение и загрузка продуктовой компоновки представлений."),
contract("2D / 3D / карта", "Синхронное переключение пространственных представлений."),
],
},
],
},
{
id: "cameras",
root: "observation",
label: "Камеры",
title: "Камеры",
eyebrow: "НАБЛЮДЕНИЕ / КАМЕРЫ",
description: "Синхронные каналы видео, изображений и глубины с пространственной разметкой.",
icon: "video",
kind: "cameras",
groups: [
{
title: "Изображение",
description: "Встроенные классы 2D-представления.",
capabilities: [
contract("Изображение и видео (Image / Video)", "Живые кадры и кодированное видео на общей временной шкале."),
contract("Карта глубины (DepthImage)", "Глубина с цветовой шкалой и масштабом в метрах."),
contract("Перспективная камера (Pinhole)", "Калибровка, разрешение и проекция 3D↔2D."),
contract("Тензоры (TensorView)", "Отладка карт признаков и многомерных выходов моделей."),
],
},
],
},
{
id: "world-map",
root: "observation",
label: "Карта",
title: "Карта и поверхность",
eyebrow: "НАБЛЮДЕНИЕ / КАРТА",
description: "Географическая, высотная и локальная карта для аппарата и миссии.",
icon: "globe",
kind: "map",
groups: [
{
title: "Представления карты",
description: "Один интерфейс для глобальной и локальной геометрии.",
capabilities: [
contract("Геоточки и линии (GeoPoints / GeoLineStrings)", "Положение аппарата, трек, геозоны и маршруты."),
contract("Карта высот (GridMap)", "Высота, уклон, неровность и проходимость."),
contract("Воксельная карта (VoxelGridMap)", "Объёмные занятые и свободные области."),
later("Картографический модуль", "Подключение grid_map, OctoMap или nvblox по профилю борта."),
],
},
],
},
{
id: "objects",
root: "observation",
label: "Объекты и разметка",
title: "Объекты и разметка",
eyebrow: "НАБЛЮДЕНИЕ / РАЗМЕТКА",
description: "Детекции, сегментация, ключевые точки и связи — без привязки к конкретной модели.",
icon: "panel",
kind: "catalog",
groups: [
{
title: "Примитивы восприятия",
description: "Готовые визуальные контракты; алгоритмы подключаются отдельными обработчиками.",
capabilities: [
contract("Рамки 2D / 3D (Boxes)", "Объекты в камере и пространстве с классом и уверенностью модели."),
contract("Сегментация (SegmentationImage)", "Семантические и объектные маски поверх видеокадра."),
contract("Ключевые точки (Points2D)", "Скелеты, ориентиры и траектории признаков."),
contract("Граф связей (GraphView)", "Связи между объектами, сценами и состояниями."),
later("Модули восприятия", "Детекторы и трекеры не входят в текущий этап интерфейса."),
],
},
],
},
{
id: "telemetry",
root: "observation",
label: "Телеметрия",
title: "Телеметрия",
eyebrow: "НАБЛЮДЕНИЕ / СИГНАЛЫ",
description: "Числовые сигналы, состояния и диагностические таблицы на общей временной оси.",
icon: "activity",
kind: "catalog",
groups: [
{
title: "Временные представления",
description: "Встроенные классы для чисел, состояний и таблиц.",
capabilities: [
active("Метрики потока в реальном времени", "Задержка, частота кадров, число точек и пропуски предпросмотра."),
contract("Временные ряды (TimeSeriesView)", "Скалярные серии, несколько линий и диапазоны."),
contract("Шкала состояний (StateTimelineView)", "Переходы режимов, ошибки и статусы подсистем."),
contract("Таблица данных (DataframeView)", "Табличная диагностика и атрибуты сущностей."),
contract("Столбчатая диаграмма (BarChartView)", "Распределения и компактные сравнения."),
],
},
],
},
{
id: "sensor-time",
root: "observation",
label: "Временная шкала",
title: "Временная шкала",
eyebrow: "НАБЛЮДЕНИЕ / ВРЕМЯ",
description: "Синхронная навигация по кадрам, событиям, сенсорам и выбранным сущностям.",
icon: "list",
kind: "timeline",
groups: [
{
title: "Управление временем",
description: "Единая шкала для реального времени и воспроизведения.",
capabilities: [
ready("Время и последовательность", "Наносекундное время или порядковые номера кадров."),
ready("Пуск, пауза и переход", "API движка поддерживает управление воспроизведением."),
contract("Синхронный выбор", "Кадр, точка, объект и телеметрия в одной позиции времени."),
contract("Следовать за эфиром", "Автоматическое удержание последнего события."),
],
},
],
},
{
id: "mission-planner",
root: "missions",
label: "Планировщик",
title: "Планировщик миссии",
eyebrow: "МИССИИ / ЧЕРНОВИК",
description: "Черновик миссии, аппараты, зона, маршрут, действия и ограничения.",
icon: "edit",
kind: "missions",
groups: [
{
title: "Состав миссии",
description: "Продуктовый контракт без отправки команд на борт.",
capabilities: [
contract("Назначение аппарата", "Один аппарат сейчас, несколько — в будущем."),
contract("Зона и маршрут", "Точки, коридор, высота/скорость и геозоны."),
contract("Полезная нагрузка", "Запуск сенсоров и требуемые выходные данные."),
later("Проверка выполнимости", "Расчёт энергии, связи, столкновений и резервов."),
],
},
],
},
{
id: "routes",
root: "missions",
label: "Маршруты и точки",
title: "Маршруты и точки",
eyebrow: "МИССИИ / МАРШРУТЫ",
description: "Библиотека маршрутов по точкам, геозон и областей обследования.",
icon: "globe",
kind: "map",
groups: [
{
title: "Геометрия задания",
description: "Маршрут является отдельной версионируемой сущностью.",
capabilities: [
contract("Маршрутные точки", "Порядок, допуск, скорость и действие в точке."),
contract("Коридоры", "Допустимый пространственный коридор движения."),
contract("Области", "Области обхода, обследования и запрета."),
later("Импорт карт", "Внешние форматы после выбора геодезической модели."),
],
},
],
},
{
id: "scenarios",
root: "missions",
label: "Сценарии",
title: "Сценарии",
eyebrow: "МИССИИ / СЦЕНАРИИ",
description: "Переиспользуемые последовательности действий и реакции на события.",
icon: "apps",
kind: "catalog",
groups: [
{
title: "Поведение",
description: "Интерфейсный контракт будущего исполнителя миссий.",
capabilities: [
contract("Последовательность", "Взлёт/старт, маршрут, наблюдение, возврат."),
contract("Условия", "Событие сенсора, качество связи, заряд и таймаут."),
contract("Ветвление", "Повтор, переход, безопасная остановка."),
later("Исполнитель на борту", "Автономное выполнение без сервера и интерфейса."),
],
},
],
},
{
id: "execution",
root: "missions",
label: "Исполнение",
title: "Исполнение миссии",
eyebrow: "МИССИИ / ИСПОЛНЕНИЕ",
description: "Наблюдение за ходом миссии и корректировки в разрешённых границах.",
icon: "activity",
kind: "timeline",
groups: [
{
title: "Оперативное исполнение",
description: "Интерфейс не входит в контур стабилизации или аварийного торможения.",
capabilities: [
contract("Прогресс", "Текущий этап, точка, отклонение и оценка завершения."),
contract("События", "Предупреждения, отклонения и запросы подтверждения."),
contract("Безопасные корректировки", "Пауза, продолжение и новая цель верхнего уровня."),
later("Командный канал", "Появится только после отдельной проверки безопасности."),
],
},
],
},
{
id: "sessions",
root: "data",
label: "Сессии и записи",
title: "Сессии и записи",
eyebrow: "ДАННЫЕ / ЗАПИСИ",
description: "Живые и записанные эксперименты с воспроизводимым набором потоков.",
icon: "folder",
kind: "catalog",
groups: [
{
title: "Артефакты",
description: "Сохраняется политика «сначала исходные данные».",
capabilities: [
active("Нативные захваты", "Сырые данные MQTT и обезличенный манифест."),
active("Воспроизведение", "Воспроизведение исходной записи MQTT и проверенного TSV."),
ready("Запись RRD", "Совместимая запись Rerun после появления потокового адаптера."),
ready("Компоновка RBL", "Версионируемая компоновка визуализатора рядом с кодом."),
],
},
],
},
{
id: "streams",
root: "data",
label: "Потоки",
title: "Потоки данных",
eyebrow: "ДАННЫЕ / ПОТОКИ",
description: "Каталог источников, транспортов, частот, задержек и схем.",
icon: "network",
kind: "catalog",
groups: [
{
title: "Транспорт",
description: "Источник отделён от визуального представления.",
capabilities: [
active("MQTT вход", "Доказанный локальный поток текущего устройства."),
active("Управление REST / WebSocket", "Состояние и управляющие операции локального адаптера."),
ready("Rerun gRPC", "Нативный источник реального времени для встроенного веб-визуализатора."),
ready("RRD по HTTP", "Открытие одной или нескольких записей."),
later("ROS 2 / Zenoh", "Бортовой и межсетевой транспорт следующего этапа."),
],
},
],
},
{
id: "entities",
root: "data",
label: "Слои и сущности",
title: "Слои и сущности",
eyebrow: "ДАННЫЕ / СУЩНОСТИ",
description: "Единое дерево аппаратов, сенсоров, объектов, пространств и визуальных переопределений.",
icon: "list",
kind: "catalog",
groups: [
{
title: "Модель сущностей",
description: "Продуктовая оболочка поверх путей сущностей Rerun.",
capabilities: [
contract("Иерархия", "Аппарат → сенсор → поток → сущность."),
contract("Видимость", "Включение слоя, режим соло и временное скрытие."),
contract("Выбор сущности", "Выбранная сущность синхронизирует сцену и инспектор."),
contract("Переопределения", "Цвет, радиус, подписи и представление на уровне сущности."),
],
},
],
},
{
id: "playback",
root: "data",
label: "Воспроизведение",
title: "Воспроизведение",
eyebrow: "ДАННЫЕ / ВОСПРОИЗВЕДЕНИЕ",
description: "Открытие записи, выбор временной шкалы, диапазона и синхронный разбор события.",
icon: "video",
kind: "timeline",
groups: [
{
title: "API воспроизведения",
description: "Движок уже предоставляет программный контроль времени.",
capabilities: [
ready("Открыть / закрыть запись", "Адрес RRD или источник Rerun gRPC."),
ready("Активная запись", "Выбор одной записи среди нескольких."),
ready("Шкала времени и диапазон", "Переключение времени и чтение границ."),
ready("Воспроизведение, пауза и переход", "Программное управление воспроизведением."),
contract("Сравнение прогонов", "Синхронный разбор нескольких записей."),
],
},
],
},
{
id: "exports",
root: "data",
label: "Экспорт",
title: "Экспорт",
eyebrow: "ДАННЫЕ / ЭКСПОРТ",
description: "Контролируемый экспорт записей, компоновок, кадров и отчётов.",
icon: "download",
kind: "catalog",
groups: [
{
title: "Выходные артефакты",
description: "Экспорт не подменяет файлы исходных данных.",
capabilities: [
contract("RRD", "Переносимая запись визуализируемых данных."),
contract("RBL", "Версионируемая компоновка."),
contract("Кадр / снимок", "Операторский снимок выбранного момента."),
active("Обезличенный манифест", "Безопасный индекс чувствительных локальных артефактов."),
],
},
],
},
{
id: "modules",
root: "system",
label: "Вычислительные модули",
title: "Вычислительные модули",
eyebrow: "СИСТЕМА / МОДУЛИ",
description: "Контракты будущих вычислительных модулей без их запуска на текущем интерфейсном этапе.",
icon: "apps",
kind: "catalog",
groups: [
{
title: "Вычислительные роли",
description: "Каждый модуль публикует стандартные выходы, интерфейс только отображает их.",
capabilities: [
active("Адаптер устройства", "Закрытый протокол текущего устройства уже изолирован."),
contract("Восприятие", "Рамки, маски, ключевые точки, траектории и семантические подписи."),
contract("Картирование", "Высота, занятость, сетки и проходимость."),
contract("Локализация", "Поза, одометрия, TF и неопределённость."),
contract("Исполнитель миссий", "Состояние графа миссии и действия верхнего уровня."),
],
},
],
},
{
id: "integrations",
root: "system",
label: "Интеграции",
title: "Интеграции",
eyebrow: "СИСТЕМА / ИНТЕГРАЦИИ",
description: "Адаптеры данных и внешние контуры с явной границей ответственности.",
icon: "network",
kind: "catalog",
groups: [
{
title: "Точки интеграции",
description: "Внешние системы не определяют продуктовую навигацию.",
capabilities: [
active("Локальный API устройства", "Текущий контур управления FastAPI."),
ready("Визуализатор Rerun", "Открытый веб-визуализатор встроен в пространственную рабочую поверхность."),
contract("Мост ROS 2", "Стандартные PointCloud2, Pose, Odometry, TF и диагностика."),
contract("Шлюз Zenoh", "Маршрутизация разрешённых потоков между бортом и сервером."),
later("Сервисы парка", "Задачи, зарядки и координация нескольких аппаратов."),
],
},
],
},
{
id: "network",
root: "system",
label: "Сеть",
title: "Сеть",
eyebrow: "СИСТЕМА / СЕТЬ",
description: "Локальный контур, бортовая сеть, ячеистая сеть/LTE и серверные маршруты.",
icon: "globe",
kind: "catalog",
groups: [
{
title: "Сетевые зоны",
description: "Потоки управления и высокой пропускной способности разделяются архитектурно.",
capabilities: [
active("Локальная сеть", "Устройство и рабочая станция в одной доступной сети."),
active("Локальное управление", "Точка приёма учётных данных и браузерный контур управления ограничены локальным хостом."),
contract("Бортовая сеть", "Сенсоры, вычислитель и маршрутизатор внутри аппарата."),
contract("Дальний транспорт", "Ячеистая сеть или LTE передаёт предпросмотр, позу, события и изменения карты."),
],
},
],
},
{
id: "audit",
root: "system",
label: "Журнал и аудит",
title: "Журнал и аудит",
eyebrow: "СИСТЕМА / АУДИТ",
description: "Технические события, действия оператора и воспроизводимость эксперимента.",
icon: "clipboard",
kind: "timeline",
groups: [
{
title: "Доказательность",
description: "Git хранит код, обезличенные метаданные и правила воспроизведения.",
capabilities: [
active("Первичное сохранение сырья", "Сырые сообщения сохраняются до декодирования предпросмотра."),
active("Манифест сессии", "Версии, хэши и обезличенные сведения о прогоне."),
contract("События аудита интерфейса", "Переходы, операции и подтверждения пользователя."),
later("Центральный сервис аудита", "Поиск и корреляция событий нескольких аппаратов."),
],
},
],
},
{
id: "settings",
root: "system",
label: "Настройки",
title: "Настройки пункта управления",
eyebrow: "СИСТЕМА / НАСТРОЙКИ",
description: "Компоновки, визуальные профили, ограничения ресурсов и локальные параметры.",
icon: "settings",
kind: "catalog",
groups: [
{
title: "Рабочая область",
description: "Настройки не смешиваются с конфигурацией физического аппарата.",
capabilities: [
contract("Профили компоновки", "Компоновки представлений для наблюдения, разбора и миссий."),
contract("Визуальные профили", "Градиенты, размеры, фон, сетка и подписи."),
ready("Ограничение памяти визуализатора", "Контроль объёма потоковых и записанных данных."),
contract("Локальные предпочтения", "Тема, единицы и поведение панелей."),
],
},
],
},
];
export function rootById(id: RootId | null): RootDefinition | null {
return id ? roots.find((root) => root.id === id) ?? null : null;
}
export function workspaceById(id: string | null): WorkspaceDefinition | null {
return id ? workspaces.find((workspace) => workspace.id === id) ?? null : null;
}
export function workspacesForRoot(root: RootId | null): WorkspaceDefinition[] {
return root ? workspaces.filter((workspace) => workspace.root === root) : [];
}
export const capabilityStatusLabel: Record<CapabilityStatus, string> = {
active: "Работает",
ready: "Готово к данным",
contract: "Интерфейс готов",
later: "Следующий этап",
};

View File

@ -0,0 +1,31 @@
export type SceneProjection = "3d" | "2d" | "map";
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
export interface SceneSettings {
projection: SceneProjection;
pointSize: number;
colorMode: PointColorMode;
palette: PointPalette;
customColor: string;
accumulationSeconds: number;
showPoints: boolean;
showTrajectory: boolean;
showGrid: boolean;
showLabels: boolean;
showCameraFrustums: boolean;
}
export const defaultSceneSettings: SceneSettings = {
projection: "3d",
pointSize: 2.5,
colorMode: "intensity",
palette: "turbo",
customColor: "#35d7c1",
accumulationSeconds: 12,
showPoints: true,
showTrajectory: true,
showGrid: true,
showLabels: false,
showCameraFrustums: true,
};

View File

@ -1,970 +1,6 @@
:root {
background: #050506;
}
html {
min-width: 320px;
min-height: 100%;
background: #050506;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background: #050506;
}
button,
input {
font: inherit;
}
#root {
min-height: 100vh;
}
.k1-console {
--k1-panel: #151517;
--k1-panel-soft: #0d0d0f;
--k1-hairline: rgba(255, 255, 255, 0.08);
--k1-accent-soft: rgb(var(--nodedc-accent-rgb) / 0.11);
--k1-success-soft: rgb(var(--nodedc-success-rgb) / 0.1);
}
.brand-lockup {
display: inline-flex;
height: 100%;
align-items: center;
color: var(--nodedc-text-primary);
font-size: 1.32rem;
font-weight: 850;
letter-spacing: -0.075em;
line-height: 1;
}
.brand-lockup span {
color: rgb(var(--nodedc-accent-rgb));
}
.product-label {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.64rem;
font-weight: 800;
letter-spacing: 0.13em;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-dot {
width: 0.48rem;
height: 0.48rem;
flex: 0 0 0.48rem;
border-radius: 999px;
background: var(--nodedc-text-muted);
}
.api-dot[data-status="online"] {
background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.28rem rgb(var(--nodedc-success-rgb) / 0.09);
}
.api-dot[data-status="checking"],
.api-dot[data-status="degraded"] {
background: rgb(var(--nodedc-warning-rgb));
}
.api-dot[data-status="offline"] {
background: rgb(var(--nodedc-danger-rgb));
}
.console-stage {
height: 100%;
overflow: auto;
overscroll-behavior: contain;
scrollbar-color: var(--nodedc-scrollbar-thumb) transparent;
}
.console-canvas {
position: relative;
width: min(100%, 112rem);
min-height: 100%;
margin: 0 auto;
padding: 0 0 2.75rem;
}
.console-canvas::before {
position: absolute;
z-index: 0;
top: -8rem;
right: -8rem;
width: 34rem;
height: 34rem;
border-radius: 50%;
background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 66%);
content: "";
pointer-events: none;
}
.console-canvas > * {
position: relative;
z-index: 1;
}
.error-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
margin-bottom: 1.25rem;
border-radius: 1.1rem;
background: rgb(var(--nodedc-danger-rgb) / 0.1);
color: color-mix(in srgb, rgb(var(--nodedc-danger-rgb)) 82%, white);
padding: 0.85rem 1rem;
}
.error-banner > svg {
align-self: start;
margin-top: 0.12rem;
}
.error-banner strong,
.error-banner p {
margin: 0;
}
.error-banner strong {
color: var(--nodedc-text-primary);
font-size: 0.8rem;
}
.error-banner p {
margin-top: 0.2rem;
color: var(--nodedc-text-secondary);
font-size: 0.74rem;
line-height: 1.45;
}
.error-banner__actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.console-hero {
display: flex;
min-height: 15.5rem;
align-items: flex-end;
justify-content: space-between;
gap: 3rem;
overflow: hidden;
scroll-margin-top: 1rem;
border-radius: var(--nodedc-radius-card);
background:
linear-gradient(112deg, rgba(255, 255, 255, 0.048), transparent 44%),
radial-gradient(circle at 79% 16%, rgb(var(--nodedc-accent-rgb) / 0.12), transparent 34%),
#0b0b0d;
padding: clamp(1.6rem, 3vw, 3rem);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045);
}
.console-hero__copy {
max-width: 46rem;
}
.section-eyebrow,
.metric-card__eyebrow {
display: block;
color: var(--nodedc-text-muted);
font-size: 0.64rem;
font-weight: 820;
letter-spacing: 0.12em;
line-height: 1.2;
}
.console-hero h1 {
max-width: 42rem;
margin: 0.65rem 0 0.9rem;
font-size: clamp(2rem, 4vw, 4.2rem);
font-weight: 710;
letter-spacing: -0.055em;
line-height: 0.98;
}
.console-hero__copy p {
max-width: 39rem;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: 0.86rem;
line-height: 1.55;
}
.console-hero__status {
display: grid;
max-width: 21rem;
justify-items: end;
gap: 0.75rem;
color: var(--nodedc-text-muted);
font-size: 0.74rem;
line-height: 1.45;
text-align: right;
}
.quick-path {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.7rem;
margin-top: 1rem;
}
.quick-path > div {
display: grid;
min-height: 6.8rem;
align-content: start;
gap: 0.42rem;
border: 1px solid var(--k1-hairline);
border-radius: 1rem;
background: var(--k1-panel-soft);
padding: 0.85rem;
}
.quick-path span {
color: rgb(var(--nodedc-accent-rgb));
font-size: 0.58rem;
font-weight: 850;
letter-spacing: 0.12em;
}
.quick-path strong {
color: var(--nodedc-text-primary);
font-size: 0.74rem;
line-height: 1.25;
}
.quick-path small {
color: var(--nodedc-text-muted);
font-size: 0.63rem;
line-height: 1.4;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.metric-card {
min-height: 9.3rem;
}
.metric-card[data-featured="true"] {
background: linear-gradient(145deg, var(--k1-accent-soft), transparent 72%), var(--k1-panel);
}
.metric-card__reading {
display: flex;
align-items: baseline;
gap: 0.45rem;
margin-top: 1.15rem;
}
.metric-card__reading strong {
font-size: clamp(1.75rem, 2.6vw, 2.7rem);
font-weight: 690;
letter-spacing: -0.055em;
line-height: 1;
}
.metric-card__reading span {
color: var(--nodedc-text-muted);
font-size: 0.76rem;
font-weight: 700;
}
.metric-card p {
margin: 0.9rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.7rem;
line-height: 1.35;
}
.console-layout {
display: grid;
grid-template-columns: minmax(21rem, 28.5rem) minmax(0, 1fr);
align-items: start;
gap: 1.25rem;
margin-top: 1.25rem;
}
.connection-panel,
.session-panel {
scroll-margin-top: 1rem;
}
.connection-panel {
background: var(--k1-panel);
}
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.panel-heading h2 {
margin: 0.42rem 0 0;
font-size: 1.3rem;
font-weight: 730;
letter-spacing: -0.035em;
line-height: 1.1;
}
.panel-heading--compact {
align-items: center;
}
.wizard-list {
display: grid;
margin-top: 1.5rem;
}
.wizard-step {
display: grid;
grid-template-columns: 2.25rem minmax(0, 1fr);
gap: 0.8rem;
}
.wizard-step__rail {
position: relative;
display: flex;
justify-content: center;
}
.wizard-step__rail::after {
position: absolute;
top: 2rem;
bottom: 0;
left: 50%;
width: 1px;
background: var(--k1-hairline);
content: "";
}
.wizard-step:last-child .wizard-step__rail::after {
display: none;
}
.wizard-step__rail span {
position: relative;
z-index: 1;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 999px;
background: #27272a;
color: var(--nodedc-text-secondary);
font-size: 0.62rem;
font-weight: 800;
}
.wizard-step__content {
min-width: 0;
padding: 0.1rem 0 1.6rem;
}
.wizard-step:last-child .wizard-step__content {
padding-bottom: 0;
}
.wizard-step__content > header {
display: flex;
min-height: 2rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.8rem;
}
.wizard-step__content h3 {
margin: 0;
font-size: 0.86rem;
font-weight: 720;
}
.step-copy,
.safety-note {
margin: 0 0 0.75rem;
color: var(--nodedc-text-muted);
font-size: 0.69rem;
line-height: 1.45;
}
.safety-note {
margin: 0.7rem 0 0;
}
.device-list {
display: grid;
max-height: 28rem;
gap: 0.5rem;
margin-top: 0.75rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.device-row {
display: grid;
gap: 0.65rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.035);
padding: 0.75rem;
}
.device-row[data-selected="true"] {
background: var(--k1-accent-soft);
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18);
}
.device-row__identity,
.device-row__action {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.device-row__identity {
justify-content: flex-start;
}
.device-row__identity > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.device-row__name {
display: flex;
min-width: 0;
align-items: center;
gap: 0.45rem;
}
.device-row__name small {
flex: 0 0 auto;
border-radius: 999px;
background: var(--k1-accent-soft);
color: rgb(var(--nodedc-accent-rgb));
padding: 0.2rem 0.42rem;
font-size: 0.5rem;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.device-row__identity strong {
overflow: hidden;
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row code,
.detail-row code {
overflow: hidden;
color: var(--nodedc-text-muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.62rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row__signal {
width: 0.6rem;
height: 0.6rem;
flex: 0 0 0.6rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.035);
}
.device-row[data-likely-k1="true"] .device-row__signal {
background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.25rem var(--k1-success-soft);
}
.device-row__action > span {
color: var(--nodedc-text-muted);
font-size: 0.66rem;
}
.empty-device-list {
border-radius: 1rem;
background: rgba(255, 255, 255, 0.025);
color: var(--nodedc-text-muted);
padding: 0.95rem;
font-size: 0.69rem;
line-height: 1.45;
}
.field-stack {
display: grid;
gap: 0.9rem;
}
.connection-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.03);
padding: 0.65rem 0.8rem;
font-size: 0.69rem;
}
.connection-summary span {
color: var(--nodedc-text-muted);
}
.connection-summary strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.monitor-column {
display: grid;
min-width: 0;
gap: 1.25rem;
}
.foxglove-panel {
position: relative;
order: 2;
display: grid;
min-height: 20rem;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
overflow: hidden;
background: #0b0b0e;
}
.foxglove-panel__grid {
position: absolute;
inset: 0;
opacity: 0.36;
background-image:
linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px);
background-position: center;
background-size: 3rem 3rem;
mask-image: radial-gradient(circle at 58% 48%, #000 0%, transparent 66%);
transform: perspective(32rem) rotateX(56deg) scale(1.42) translateY(18%);
}
.foxglove-panel::after {
position: absolute;
top: 16%;
right: 14%;
width: 13rem;
height: 13rem;
border-radius: 50%;
background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 68%);
content: "";
pointer-events: none;
}
.foxglove-panel__content,
.foxglove-panel__action {
position: relative;
z-index: 1;
}
.foxglove-panel__content {
max-width: 37rem;
}
.foxglove-panel h2 {
margin: 0.5rem 0 0.7rem;
font-size: clamp(1.6rem, 3vw, 3rem);
font-weight: 690;
letter-spacing: -0.05em;
line-height: 1;
}
.foxglove-panel p {
max-width: 33rem;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: 0.78rem;
line-height: 1.55;
}
.topic-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.2rem;
}
.topic-list code {
border-radius: 999px;
background: rgba(255, 255, 255, 0.055);
color: var(--nodedc-text-secondary);
padding: 0.42rem 0.7rem;
font-size: 0.64rem;
}
.foxglove-panel__action {
display: grid;
justify-items: end;
gap: 0.75rem;
}
.monitor-grid {
display: grid;
order: 3;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
.status-panel,
.latency-panel,
.session-panel {
background: var(--k1-panel);
}
.session-panel {
order: 1;
}
.detail-list {
display: grid;
gap: 0;
margin: 1rem 0 0;
}
.detail-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
gap: 1rem;
border-top: 1px solid var(--k1-hairline);
padding: 0.75rem 0;
}
.detail-row dt,
.detail-row dd {
min-width: 0;
margin: 0;
font-size: 0.7rem;
}
.detail-row dt {
color: var(--nodedc-text-muted);
}
.detail-row dd {
overflow: hidden;
color: var(--nodedc-text-secondary);
text-align: right;
text-overflow: ellipsis;
text-transform: capitalize;
white-space: nowrap;
}
.inline-state {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.inline-state::before {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.inline-state[data-state="open"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.inline-state[data-state="error"]::before,
.inline-state[data-state="closed"]::before {
background: rgb(var(--nodedc-danger-rgb));
}
.latency-now {
color: var(--nodedc-text-primary);
font-size: 1.35rem;
font-weight: 680;
letter-spacing: -0.04em;
}
.latency-now span {
color: var(--nodedc-text-muted);
font-size: 0.66rem;
letter-spacing: 0;
}
.latency-trace {
display: flex;
height: 8.4rem;
align-items: end;
gap: 0.22rem;
margin-top: 1.25rem;
border-radius: 0.9rem;
background:
linear-gradient(to top, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
rgba(255, 255, 255, 0.018);
background-size: 100% 25%;
padding: 0.75rem;
}
.latency-trace span {
min-width: 0.18rem;
flex: 1 1 0;
border-radius: 999px 999px 0.14rem 0.14rem;
background: linear-gradient(to top, rgb(var(--nodedc-accent-rgb) / 0.35), rgb(var(--nodedc-accent-rgb)));
}
.latency-trace p {
align-self: center;
margin: auto;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.45;
text-align: center;
}
.latency-legend {
display: flex;
justify-content: space-between;
margin-top: 0.4rem;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.session-panel > .nodedc-segmented {
margin-top: 1.4rem;
}
.session-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.85rem;
margin-top: 1rem;
}
.session-form--replay {
grid-template-columns: minmax(14rem, 1fr) minmax(8rem, 0.3fr) minmax(12rem, 0.5fr) auto;
}
.live-instruction {
grid-column: 1 / -1;
margin: 0;
border-left: 2px solid rgb(var(--nodedc-accent-rgb));
color: var(--nodedc-text-secondary);
padding: 0.15rem 0 0.15rem 0.75rem;
font-size: 0.7rem;
line-height: 1.5;
}
.session-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1.25rem;
border-top: 1px solid var(--k1-hairline);
padding-top: 1rem;
}
.session-footer p {
max-width: 42rem;
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.5;
}
@media (max-width: 1320px) {
.quick-path {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.console-layout {
grid-template-columns: minmax(20rem, 24rem) minmax(0, 1fr);
}
.monitor-grid {
grid-template-columns: 1fr;
}
.session-form--replay {
grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.3fr);
}
.session-form--replay > :nth-child(3),
.session-form--replay > :nth-child(4) {
grid-column: span 1;
}
}
@media (max-width: 980px) {
.console-layout {
grid-template-columns: 1fr;
}
.foxglove-panel {
min-height: 18rem;
}
}
@media (max-width: 760px) {
.product-label {
display: none;
}
.console-canvas {
padding-bottom: 1.5rem;
}
.error-banner {
grid-template-columns: auto minmax(0, 1fr);
}
.error-banner__actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.console-hero {
min-height: 20rem;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 1.4rem;
}
.console-hero h1 {
font-size: 2.5rem;
}
.console-hero__status {
max-width: none;
justify-items: start;
text-align: left;
}
.metrics-grid {
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
margin-top: 0.7rem;
}
.quick-path {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin-top: 0.7rem;
}
.metric-card {
min-height: 8.2rem;
}
.console-layout,
.monitor-column,
.monitor-grid {
gap: 0.7rem;
margin-top: 0.7rem;
}
.foxglove-panel {
grid-template-columns: 1fr;
gap: 2rem;
}
.foxglove-panel__action {
justify-items: start;
}
.session-form,
.session-form--replay {
grid-template-columns: 1fr;
}
.session-form--replay > :nth-child(3),
.session-form--replay > :nth-child(4) {
grid-column: auto;
}
.session-footer {
align-items: stretch;
flex-direction: column;
}
}
@media (max-width: 480px) {
.quick-path {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: 1fr;
}
.panel-heading {
align-items: flex-start;
flex-direction: column;
}
.wizard-step {
grid-template-columns: 1.8rem minmax(0, 1fr);
gap: 0.55rem;
}
.wizard-step__rail span {
width: 1.65rem;
height: 1.65rem;
}
.wizard-step__rail::after {
top: 1.65rem;
}
.wizard-step__content > header {
align-items: flex-start;
flex-direction: column;
}
.session-panel > .nodedc-segmented {
display: grid;
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@import "./styles/base.css";
@import "./styles/shell.css";
@import "./styles/workspaces.css";
@import "./styles/spatial.css";
@import "./styles/device.css";
@import "./styles/responsive.css";

View File

@ -0,0 +1,293 @@
:root {
background: #050506;
}
html,
body,
#root {
width: 100%;
height: 100%;
min-width: 320px;
min-height: 100%;
overflow: hidden;
background: #050506;
}
body {
margin: 0;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
color: inherit;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.control-station {
--station-panel: #151517;
--station-panel-soft: #0d0d0f;
--station-stage: #08090b;
--station-hairline: rgb(255 255 255 / 0.08);
--station-hairline-strong: rgb(255 255 255 / 0.13);
--station-accent-soft: rgb(var(--nodedc-accent-rgb) / 0.11);
--station-success-soft: rgb(var(--nodedc-success-rgb) / 0.1);
--station-warning-soft: rgb(var(--nodedc-warning-rgb) / 0.1);
--station-danger-soft: rgb(var(--nodedc-danger-rgb) / 0.1);
}
.control-station .nodedc-application-panel__body {
overscroll-behavior: contain;
scrollbar-color: var(--nodedc-scrollbar-thumb) transparent;
}
.section-eyebrow,
.metric-card__eyebrow {
display: block;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
font-weight: 820;
letter-spacing: 0.12em;
line-height: 1.2;
text-transform: uppercase;
}
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.panel-heading h2,
.panel-heading h3 {
margin: 0.42rem 0 0;
color: var(--nodedc-text-primary);
font-size: 1.22rem;
font-weight: 730;
letter-spacing: -0.035em;
line-height: 1.1;
}
.panel-heading--compact {
align-items: center;
}
.workspace-lead {
display: flex;
min-width: 0;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
}
.workspace-lead > div:first-child {
min-width: 0;
max-width: 62rem;
}
.workspace-lead h2 {
margin: 0.5rem 0 0.6rem;
color: var(--nodedc-text-primary);
font-size: clamp(1.7rem, 3vw, 3rem);
font-weight: 690;
letter-spacing: -0.05em;
line-height: 1;
}
.workspace-lead p {
max-width: 50rem;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: 0.78rem;
line-height: 1.55;
}
.workspace-lead__note {
max-width: 20rem;
flex: 0 0 auto;
border-radius: var(--nodedc-radius-circle);
background: rgb(255 255 255 / 0.045);
color: var(--nodedc-text-muted);
padding: 0.58rem 0.85rem;
font-size: 0.66rem;
line-height: 1.35;
text-align: right;
}
.workspace-lead__status {
display: grid;
max-width: 22rem;
justify-items: end;
gap: 0.55rem;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.45;
text-align: right;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.metric-card {
min-height: 7rem;
background: var(--station-panel);
}
.metric-card[data-featured="true"] {
background:
radial-gradient(circle at 90% 0%, rgb(var(--nodedc-accent-rgb) / 0.16), transparent 44%),
var(--station-panel);
}
.metric-card__reading {
display: flex;
align-items: baseline;
gap: 0.36rem;
margin-top: 0.65rem;
}
.metric-card__reading strong {
color: var(--nodedc-text-primary);
font-size: clamp(1.4rem, 2vw, 2.2rem);
font-weight: 650;
letter-spacing: -0.05em;
line-height: 1;
}
.metric-card__reading span,
.metric-card p {
color: var(--nodedc-text-muted);
font-size: 0.64rem;
}
.metric-card p {
margin: 0.6rem 0 0;
line-height: 1.4;
}
.modal-stack,
.inspector-control-stack {
display: grid;
gap: 1rem;
}
.modal-contract-note {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.8rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.045);
padding: 0.9rem;
}
.modal-contract-note[data-tone="accent"] {
background: var(--station-accent-soft);
}
.modal-contract-note[data-tone="warning"] {
background: var(--station-warning-soft);
}
.modal-contract-note[data-tone="success"] {
background: var(--station-success-soft);
}
.modal-contract-note > svg {
margin-top: 0.12rem;
color: var(--nodedc-text-secondary);
}
.modal-contract-note strong,
.modal-contract-note p {
margin: 0;
}
.modal-contract-note strong {
color: var(--nodedc-text-primary);
font-size: 0.76rem;
}
.modal-contract-note p {
margin-top: 0.25rem;
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
line-height: 1.45;
}
.source-format-list {
display: grid;
gap: 0.5rem;
}
.source-format-list > div {
display: grid;
grid-template-columns: minmax(12rem, 0.9fr) minmax(0, 1.1fr);
gap: 1rem;
border-top: 1px solid var(--station-hairline);
padding: 0.7rem 0;
}
.source-format-list code {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.65rem;
text-overflow: ellipsis;
}
.source-format-list span {
color: var(--nodedc-text-muted);
font-size: 0.67rem;
}
.layer-inspector-intro {
display: grid;
justify-items: start;
gap: 0.65rem;
margin-bottom: 1rem;
}
.layer-inspector-intro p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.69rem;
line-height: 1.45;
}
.layer-placeholder-list {
display: grid;
gap: 0.5rem;
}
.layer-placeholder-list > span {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.55rem;
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
}
.layer-placeholder-list i {
width: 0.46rem;
height: 0.46rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.layer-placeholder-list small {
color: var(--nodedc-text-muted);
font-size: 0.6rem;
}

View File

@ -0,0 +1,420 @@
.device-workspace__grid {
display: grid;
min-width: 0;
grid-template-columns: minmax(23rem, 0.78fr) minmax(34rem, 1.22fr);
align-items: start;
gap: 0.85rem;
}
.device-workspace__side {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.connection-panel,
.status-panel,
.latency-panel,
.session-panel {
background: var(--station-panel);
}
.error-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
border-radius: 1rem;
background: var(--station-danger-soft);
color: rgb(var(--nodedc-danger-rgb));
padding: 0.85rem 1rem;
}
.error-banner > svg {
align-self: start;
margin-top: 0.12rem;
}
.error-banner strong,
.error-banner p {
margin: 0;
}
.error-banner strong {
color: var(--nodedc-text-primary);
font-size: 0.76rem;
}
.error-banner p {
margin-top: 0.2rem;
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
line-height: 1.45;
}
.error-banner__actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.wizard-list {
display: grid;
margin-top: 1.4rem;
}
.wizard-step {
display: grid;
grid-template-columns: 2.2rem minmax(0, 1fr);
gap: 0.75rem;
}
.wizard-step__rail {
position: relative;
display: flex;
justify-content: center;
}
.wizard-step__rail::after {
position: absolute;
top: 2rem;
bottom: 0;
left: 50%;
width: 1px;
background: var(--station-hairline);
content: "";
}
.wizard-step:last-child .wizard-step__rail::after {
display: none;
}
.wizard-step__rail span {
position: relative;
z-index: 1;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 50%;
background: #27272a;
color: var(--nodedc-text-secondary);
font-size: 0.59rem;
font-weight: 800;
}
.wizard-step__content {
min-width: 0;
padding: 0.08rem 0 1.5rem;
}
.wizard-step:last-child .wizard-step__content {
padding-bottom: 0;
}
.wizard-step__content > header {
display: flex;
min-height: 2rem;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
margin-bottom: 0.75rem;
}
.wizard-step__content h3 {
margin: 0;
font-size: 0.8rem;
font-weight: 710;
}
.step-copy,
.safety-note,
.live-instruction {
margin: 0 0 0.72rem;
color: var(--nodedc-text-muted);
font-size: 0.65rem;
line-height: 1.48;
}
.safety-note,
.live-instruction {
margin: 0.7rem 0 0;
}
.device-list {
display: grid;
max-height: 20rem;
gap: 0.48rem;
margin-top: 0.72rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.device-row {
display: grid;
gap: 0.62rem;
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.035);
padding: 0.72rem;
}
.device-row[data-selected="true"] {
background: var(--station-accent-soft);
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18);
}
.device-row__identity,
.device-row__action {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.device-row__identity {
justify-content: flex-start;
}
.device-row__identity > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.device-row__name {
display: flex;
min-width: 0;
align-items: center;
gap: 0.42rem;
}
.device-row__name small {
flex: 0 0 auto;
border-radius: 999px;
background: var(--station-accent-soft);
color: rgb(var(--nodedc-accent-rgb));
padding: 0.18rem 0.38rem;
font-size: 0.47rem;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.device-row__identity strong {
overflow: hidden;
font-size: 0.69rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row code,
.detail-row code {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row__signal {
width: 0.58rem;
height: 0.58rem;
flex: 0 0 0.58rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
box-shadow: 0 0 0 0.24rem rgb(255 255 255 / 0.035);
}
.device-row[data-compatible="true"] .device-row__signal {
background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.24rem var(--station-success-soft);
}
.device-row__action > span {
color: var(--nodedc-text-muted);
font-size: 0.62rem;
}
.empty-device-list {
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.025);
color: var(--nodedc-text-muted);
padding: 0.9rem;
font-size: 0.65rem;
line-height: 1.45;
}
.field-stack,
.session-form {
display: grid;
gap: 0.85rem;
}
.connection-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 0.72rem 0;
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.03);
padding: 0.62rem 0.75rem;
font-size: 0.65rem;
}
.connection-summary span {
color: var(--nodedc-text-muted);
}
.connection-summary strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-panel > .nodedc-segmented {
margin-top: 1.2rem;
}
.session-form {
margin-top: 1rem;
}
.session-form--replay {
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
}
.session-form--replay > :first-child {
grid-column: 1 / -1;
}
.session-form--replay > .nodedc-button {
align-self: end;
}
.session-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
border-top: 1px solid var(--station-hairline);
padding-top: 0.9rem;
}
.session-footer p {
max-width: 30rem;
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.45;
}
.diagnostics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.detail-list {
display: grid;
margin: 0.9rem 0 0;
}
.detail-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
gap: 1rem;
border-top: 1px solid var(--station-hairline);
padding: 0.7rem 0;
}
.detail-row dt,
.detail-row dd {
min-width: 0;
margin: 0;
font-size: 0.65rem;
}
.detail-row dt {
color: var(--nodedc-text-muted);
}
.detail-row dd {
overflow: hidden;
color: var(--nodedc-text-secondary);
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.inline-state {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.inline-state::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.inline-state[data-state="open"]::before { background: rgb(var(--nodedc-success-rgb)); }
.inline-state[data-state="error"]::before,
.inline-state[data-state="closed"]::before { background: rgb(var(--nodedc-danger-rgb)); }
.latency-now {
color: var(--nodedc-text-primary);
font-size: 1.2rem;
font-weight: 660;
letter-spacing: -0.04em;
}
.latency-now span {
color: var(--nodedc-text-muted);
font-size: 0.61rem;
letter-spacing: 0;
}
.latency-trace {
display: flex;
height: 7rem;
align-items: end;
gap: 0.2rem;
margin-top: 1rem;
border-radius: 0.85rem;
background:
linear-gradient(to top, rgb(255 255 255 / 0.035) 1px, transparent 1px),
rgb(255 255 255 / 0.018);
background-size: 100% 25%;
padding: 0.7rem;
}
.latency-trace span {
min-width: 0.16rem;
flex: 1 1 0;
border-radius: 999px 999px 0.12rem 0.12rem;
background: linear-gradient(to top, rgb(var(--nodedc-accent-rgb) / 0.35), rgb(var(--nodedc-accent-rgb)));
}
.latency-trace p {
align-self: center;
margin: auto;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.45;
text-align: center;
}
.latency-legend {
display: flex;
justify-content: space-between;
margin-top: 0.38rem;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}

View File

@ -0,0 +1,278 @@
@media (max-width: 1480px) {
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.device-workspace__grid {
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
}
.landing-stage__copy {
width: min(35rem, 54%);
}
}
@media (max-width: 1280px) {
.device-workspace__grid,
.overview-grid,
.mission-layout {
grid-template-columns: 1fr;
}
.pipeline-strip {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.pipeline-strip > svg {
display: none;
}
.map-layout {
grid-template-columns: 1fr;
}
.map-inspector {
display: grid;
grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.45fr);
gap: 0.5rem 1rem;
}
.map-inspector > .section-eyebrow,
.map-inspector > h3 {
grid-column: 1;
}
.map-inspector .layer-summary-list {
grid-row: 1 / 4;
grid-column: 2;
}
.map-inspector > .nodedc-button {
grid-column: 1;
}
.camera-grid {
grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(20rem, 1fr) repeat(2, minmax(10rem, 0.55fr));
}
.camera-slot[data-primary="true"] {
grid-row: 1;
grid-column: 1 / -1;
}
}
@media (max-width: 1040px) {
.feature-group {
grid-template-columns: 1fr;
gap: 0.9rem;
}
.capability-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.diagnostics-grid {
grid-template-columns: 1fr;
}
.workspace-lead {
align-items: flex-start;
flex-direction: column;
gap: 0.7rem;
}
.workspace-lead__note,
.workspace-lead__status {
max-width: none;
justify-items: start;
text-align: left;
}
.landing-stage__status {
width: 20rem;
max-width: 36vw;
}
.landing-stage__copy h1 {
font-size: clamp(2.7rem, 7vw, 5.6rem);
}
.scene-metrics {
display: none;
}
}
@media (max-width: 760px) {
.station-label,
.control-station .nodedc-header__profile-button {
display: none;
}
.landing-stage__copy {
top: 42%;
right: 1.2rem;
left: 1.2rem;
width: auto;
}
.landing-stage__copy h1 {
font-size: clamp(2.8rem, 14vw, 5rem);
}
.landing-stage__field {
opacity: 0.62;
}
.landing-stage__orbit,
.landing-stage__sweep {
left: 74%;
}
.landing-stage__status {
top: auto;
right: 1rem;
bottom: 3.3rem;
left: 1rem;
width: auto;
max-width: none;
}
.landing-stage__footer {
right: 1rem;
bottom: 1.1rem;
left: 1rem;
}
.landing-stage__footer span:first-child {
display: none;
}
.metrics-grid,
.capability-summary,
.camera-grid {
grid-template-columns: 1fr;
}
.camera-grid {
grid-template-rows: repeat(4, minmax(14rem, 1fr));
}
.camera-slot[data-primary="true"] {
grid-row: auto;
grid-column: auto;
}
.spatial-workspace {
min-height: 38rem;
}
.spatial-toolbar,
.spatial-toolbar__actions {
align-items: stretch;
flex-direction: column;
}
.spatial-toolbar__actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.spatial-toolbar__actions .nodedc-button {
justify-content: center;
}
.scene-status--top-left {
top: 0.6rem;
left: 0.6rem;
}
.scene-adapter-note {
right: 0.75rem;
left: 0.75rem;
width: auto;
transform: translateY(4.8rem);
}
.scene-timeline {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.scene-timeline > .nodedc-button:first-child,
.scene-timeline > code {
display: none;
}
.map-inspector {
display: block;
}
.timeline-ruler {
margin-left: 0;
}
.timeline-lane {
grid-template-columns: 5.5rem minmax(0, 1fr);
}
.timeline-lane small {
display: none;
}
.session-form--replay {
grid-template-columns: 1fr;
}
.session-form--replay > :first-child {
grid-column: auto;
}
.session-footer,
.error-banner,
.source-format-list > div {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.error-banner > svg {
display: none;
}
.error-banner__actions {
justify-content: flex-end;
}
.device-row__action {
align-items: stretch;
flex-direction: column;
}
.feature-row {
grid-template-columns: auto minmax(0, 1fr);
}
.feature-row > .nodedc-status-badge {
grid-column: 2;
justify-self: start;
}
}
@media (max-height: 720px) and (min-width: 761px) {
.landing-stage__copy h1 {
font-size: clamp(2.7rem, 6vw, 5rem);
}
.landing-stage__copy p {
font-size: 0.76rem;
}
.landing-stage__actions {
margin-top: 1rem;
}
.landing-stage__status {
top: 1.2rem;
right: 1.2rem;
}
}

View File

@ -0,0 +1,253 @@
.station-label {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.61rem;
font-weight: 820;
letter-spacing: 0.13em;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-dot {
width: 0.48rem;
height: 0.48rem;
flex: 0 0 0.48rem;
border-radius: 999px;
background: var(--nodedc-text-muted);
}
.api-dot[data-status="online"] {
background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.28rem rgb(var(--nodedc-success-rgb) / 0.09);
}
.api-dot[data-status="checking"],
.api-dot[data-status="degraded"] {
background: rgb(var(--nodedc-warning-rgb));
}
.api-dot[data-status="offline"] {
background: rgb(var(--nodedc-danger-rgb));
}
.landing-stage {
--landing-accent: var(--nodedc-accent-rgb);
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background:
linear-gradient(115deg, rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5)),
var(--station-stage);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
.landing-stage[data-root="center"] { --landing-accent: 84 177 255; }
.landing-stage[data-root="fleet"] { --landing-accent: 81 215 179; }
.landing-stage[data-root="observation"] { --landing-accent: 255 79 163; }
.landing-stage[data-root="missions"] { --landing-accent: 151 104 255; }
.landing-stage[data-root="data"] { --landing-accent: 93 192 255; }
.landing-stage[data-root="system"] { --landing-accent: 242 175 76; }
.landing-stage__field,
.landing-stage__shade,
.landing-stage__grid,
.landing-stage__orbit,
.landing-stage__sweep {
position: absolute;
inset: 0;
}
.landing-stage__field {
overflow: hidden;
background:
radial-gradient(circle at 68% 42%, rgb(var(--landing-accent) / 0.1), transparent 24%),
radial-gradient(circle at 76% 48%, rgb(255 255 255 / 0.035), transparent 42%);
}
.landing-stage__grid {
inset: -40% -25% -60% 25%;
opacity: 0.42;
background-image:
linear-gradient(rgb(255 255 255 / 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.055) 1px, transparent 1px);
background-position: center;
background-size: 4.2rem 4.2rem;
mask-image: radial-gradient(circle at center, #000 0%, transparent 68%);
transform: perspective(42rem) rotateX(62deg) rotateZ(-8deg) scale(1.3) translateY(15%);
}
.landing-stage__orbit {
top: 50%;
left: 69%;
border: 1px solid rgb(var(--landing-accent) / 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
}
.landing-stage__orbit--one {
width: min(22vw, 24rem);
height: min(22vw, 24rem);
animation: station-orbit 18s linear infinite;
}
.landing-stage__orbit--two {
width: min(34vw, 38rem);
height: min(34vw, 38rem);
border-color: rgb(var(--landing-accent) / 0.12);
animation: station-orbit-reverse 26s linear infinite;
}
.landing-stage__orbit--three {
width: min(49vw, 56rem);
height: min(49vw, 56rem);
border-color: rgb(255 255 255 / 0.06);
}
.landing-stage__orbit::before,
.landing-stage__orbit::after {
position: absolute;
width: 0.36rem;
height: 0.36rem;
border-radius: 50%;
background: rgb(var(--landing-accent));
box-shadow: 0 0 1.1rem rgb(var(--landing-accent) / 0.6);
content: "";
}
.landing-stage__orbit::before { top: -0.18rem; left: 50%; }
.landing-stage__orbit::after { right: 11%; bottom: 14%; opacity: 0.55; }
.landing-stage__sweep {
left: 69%;
width: min(32vw, 34rem);
height: min(32vw, 34rem);
margin: auto;
border-radius: 50%;
background: conic-gradient(from 220deg, transparent 0deg 318deg, rgb(var(--landing-accent) / 0.14) 344deg, transparent 360deg);
transform: translateX(-50%);
animation: station-sweep 7s linear infinite;
}
.landing-stage__node {
position: absolute;
width: 0.48rem;
height: 0.48rem;
border-radius: 50%;
background: rgb(var(--landing-accent));
box-shadow: 0 0 1.3rem rgb(var(--landing-accent) / 0.85);
}
.landing-stage__node--one { top: 29%; left: 62%; }
.landing-stage__node--two { top: 42%; left: 78%; animation: station-pulse 3.2s ease-in-out infinite; }
.landing-stage__node--three { top: 61%; left: 69%; animation: station-pulse 4.1s ease-in-out infinite 0.8s; }
.landing-stage__node--four { top: 52%; left: 87%; animation: station-pulse 3.6s ease-in-out infinite 1.4s; }
.landing-stage__shade {
background:
linear-gradient(90deg, rgb(5 5 6 / 0.96) 0%, rgb(5 5 6 / 0.76) 34%, transparent 67%),
linear-gradient(0deg, rgb(5 5 6 / 0.86) 0%, transparent 28%);
}
.landing-stage__copy {
position: absolute;
z-index: 2;
top: 50%;
left: clamp(2rem, 5vw, 6rem);
width: min(39rem, 50%);
transform: translateY(-55%);
}
.landing-stage__copy h1 {
margin: 0.75rem 0 1rem;
color: var(--nodedc-text-primary);
font-size: clamp(3rem, 7vw, 7.6rem);
font-weight: 600;
letter-spacing: -0.072em;
line-height: 0.87;
}
.landing-stage__copy p {
max-width: 34rem;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: clamp(0.8rem, 1vw, 1rem);
line-height: 1.6;
}
.landing-stage__actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1.6rem;
}
.landing-stage__status {
position: absolute;
z-index: 2;
top: 2.4rem;
right: 2.4rem;
display: grid;
width: min(24rem, 30vw);
gap: 0.6rem;
border: 1px solid rgb(255 255 255 / 0.055);
border-radius: 1.25rem;
background: rgb(10 10 12 / 0.58);
padding: 1rem;
backdrop-filter: blur(18px);
}
.landing-stage__status > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.landing-stage__status p {
margin: 0.15rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
line-height: 1.45;
}
.landing-stage__footer {
position: absolute;
z-index: 2;
right: 2.4rem;
bottom: 1.8rem;
left: 2.4rem;
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
font-weight: 780;
letter-spacing: 0.12em;
}
@keyframes station-orbit {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes station-orbit-reverse {
to { transform: translate(-50%, -50%) rotate(-360deg); }
}
@keyframes station-sweep {
to { transform: translateX(-50%) rotate(360deg); }
}
@keyframes station-pulse {
0%, 100% { opacity: 0.34; transform: scale(0.75); }
50% { opacity: 1; transform: scale(1.2); }
}
@media (prefers-reduced-motion: reduce) {
.landing-stage__orbit,
.landing-stage__sweep,
.landing-stage__node {
animation: none;
}
}

View File

@ -0,0 +1,347 @@
.spatial-workspace {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
min-height: 34rem;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 0.65rem;
overflow: hidden;
}
.spatial-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.spatial-toolbar__mode,
.spatial-toolbar__actions {
display: flex;
min-width: 0;
align-items: center;
gap: 0.45rem;
}
.spatial-viewport-shell {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: 1rem;
background: #06070a;
box-shadow: inset 0 0 0 1px var(--station-hairline);
}
.rerun-viewport,
.rerun-viewport__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.rerun-viewport__canvas canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.rerun-viewport__notice {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
gap: 0.8rem;
border: 1px solid var(--station-hairline);
border-radius: 1rem;
background: rgb(10 11 14 / 0.82);
padding: 0.9rem 1rem;
transform: translate(-50%, -50%);
backdrop-filter: blur(18px);
}
.rerun-viewport__notice strong,
.rerun-viewport__notice span {
display: block;
}
.rerun-viewport__notice strong {
font-size: 0.72rem;
}
.rerun-viewport__notice span {
margin-top: 0.18rem;
color: var(--nodedc-text-muted);
font-size: 0.61rem;
}
.rerun-viewport__notice--error {
background: rgb(var(--nodedc-danger-rgb) / 0.12);
}
.busy-indicator {
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
border: 2px solid rgb(255 255 255 / 0.12);
border-top-color: rgb(var(--nodedc-accent-rgb));
border-radius: 50%;
animation: viewer-spin 900ms linear infinite;
}
@keyframes viewer-spin {
to { transform: rotate(360deg); }
}
.empty-spatial-stage {
position: absolute;
inset: 0;
overflow: hidden;
background:
radial-gradient(circle at 68% 41%, rgb(var(--nodedc-accent-rgb) / 0.075), transparent 28%),
linear-gradient(125deg, #08090c, #040508);
}
.empty-spatial-stage__grid {
position: absolute;
inset: -25% -15% -85%;
opacity: 0;
background-image:
linear-gradient(rgb(255 255 255 / 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.07) 1px, transparent 1px);
background-position: center;
background-size: 3.7rem 3.7rem;
mask-image: radial-gradient(circle at center, #000 0%, transparent 70%);
transform: perspective(40rem) rotateX(66deg) rotateZ(-5deg) scale(1.45) translateY(8%);
}
.empty-spatial-stage[data-grid="true"] .empty-spatial-stage__grid {
opacity: 0.52;
}
.spatial-axis {
position: absolute;
right: 2rem;
bottom: 5.2rem;
width: 4.4rem;
height: 4.4rem;
color: var(--nodedc-text-muted);
font-size: 0.53rem;
}
.spatial-axis::before,
.spatial-axis::after {
position: absolute;
bottom: 0.65rem;
left: 0.75rem;
width: 2.7rem;
height: 1px;
background: rgb(255 255 255 / 0.2);
content: "";
transform-origin: left center;
}
.spatial-axis::before { transform: rotate(-24deg); }
.spatial-axis::after { transform: rotate(-90deg); }
.spatial-axis span { position: absolute; }
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: #ff6a7a; }
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: #75d98a; }
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: #67a9ff; }
.empty-spatial-stage__message {
position: absolute;
top: 50%;
left: 50%;
display: grid;
max-width: 25rem;
justify-items: center;
gap: 0.62rem;
transform: translate(-50%, -58%);
text-align: center;
}
.empty-spatial-stage__icon {
display: grid;
width: 3.4rem;
height: 3.4rem;
place-items: center;
border: 1px solid var(--station-hairline-strong);
border-radius: 50%;
background: rgb(255 255 255 / 0.035);
color: var(--nodedc-text-secondary);
}
.empty-spatial-stage__message strong {
color: var(--nodedc-text-primary);
font-size: 0.82rem;
}
.empty-spatial-stage__message p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.67rem;
line-height: 1.5;
}
.scene-status,
.scene-metrics,
.scene-adapter-note,
.scene-selection,
.scene-timeline {
position: absolute;
z-index: 3;
border: 1px solid rgb(255 255 255 / 0.055);
background: rgb(9 10 13 / 0.74);
backdrop-filter: blur(16px);
}
.scene-status--top-left {
top: 0.85rem;
left: 0.85rem;
display: grid;
justify-items: start;
gap: 0.42rem;
border-radius: 0.9rem;
padding: 0.7rem;
}
.scene-status small {
max-width: 18rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
line-height: 1.35;
}
.scene-metrics {
top: 0.85rem;
right: 0.85rem;
display: grid;
min-width: 10rem;
border-radius: 0.9rem;
padding: 0.35rem 0.75rem;
}
.scene-metrics > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--station-hairline);
padding: 0.38rem 0;
}
.scene-metrics > div:last-child { border-bottom: 0; }
.scene-metrics span { color: var(--nodedc-text-muted); font-size: 0.56rem; }
.scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; }
.scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; }
.scene-adapter-note {
top: 50%;
left: 50%;
display: flex;
max-width: 34rem;
align-items: center;
gap: 0.65rem;
border-radius: 0.9rem;
color: var(--nodedc-text-secondary);
padding: 0.75rem 0.9rem;
font-size: 0.63rem;
line-height: 1.4;
transform: translate(-50%, 5.2rem);
}
.scene-adapter-note > svg { flex: 0 0 auto; color: rgb(var(--nodedc-warning-rgb)); }
.scene-selection {
right: 0.85rem;
bottom: 4.6rem;
display: grid;
max-width: 19rem;
gap: 0.2rem;
border-radius: 0.85rem;
padding: 0.65rem 0.75rem;
}
.scene-selection span,
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
.scene-timeline {
right: 0.75rem;
bottom: 0.75rem;
left: 0.75rem;
display: grid;
min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
border-radius: 0.9rem;
padding: 0.4rem;
}
.scene-timeline__track {
height: 0.3rem;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
}
.scene-timeline__track span {
display: block;
height: 100%;
border-radius: inherit;
background: rgb(var(--nodedc-accent-rgb));
}
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
.scene-timeline__follow {
border-radius: var(--nodedc-radius-circle);
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-muted);
padding: 0.3rem 0.45rem;
font-size: 0.52rem;
font-weight: 820;
}
.scene-timeline__follow[data-active="true"] {
background: var(--station-success-soft);
color: rgb(var(--nodedc-success-rgb));
}
.spatial-contract-strip {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem 1rem;
color: var(--nodedc-text-muted);
font-size: 0.57rem;
}
.spatial-contract-strip span {
display: inline-flex;
align-items: center;
gap: 0.38rem;
}
.spatial-contract-strip i {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.spatial-contract-strip i[data-state="ready"] { background: rgb(var(--nodedc-accent-rgb)); }
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
@media (prefers-reduced-motion: reduce) {
.busy-indicator { animation: none; }
}

View File

@ -0,0 +1,628 @@
.standard-workspace,
.device-workspace {
display: grid;
min-width: 0;
gap: 1rem;
padding: 0 0 1rem;
}
.feature-inventory {
display: grid;
gap: 1rem;
}
.feature-group {
display: grid;
min-width: 0;
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.6fr);
gap: 1.6rem;
border-top: 1px solid var(--station-hairline);
padding-top: 1.2rem;
}
.feature-group > header h3 {
margin: 0.48rem 0 0.45rem;
color: var(--nodedc-text-primary);
font-size: 1.08rem;
font-weight: 710;
letter-spacing: -0.035em;
}
.feature-group > header p {
max-width: 25rem;
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.48;
}
.feature-list {
display: grid;
min-width: 0;
}
.feature-row {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.8rem;
border-bottom: 1px solid var(--station-hairline);
padding: 0.78rem 0;
}
.feature-row:first-child {
padding-top: 0;
}
.feature-row__mark {
width: 0.52rem;
height: 0.52rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.feature-row[data-status="active"] .feature-row__mark {
background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.25rem var(--station-success-soft);
}
.feature-row[data-status="ready"] .feature-row__mark {
background: rgb(var(--nodedc-accent-rgb));
box-shadow: 0 0 0 0.25rem var(--station-accent-soft);
}
.feature-row[data-status="contract"] .feature-row__mark {
background: rgb(var(--nodedc-warning-rgb));
box-shadow: 0 0 0 0.25rem var(--station-warning-soft);
}
.feature-row strong,
.feature-row p {
margin: 0;
}
.feature-row strong {
color: var(--nodedc-text-primary);
font-size: 0.74rem;
font-weight: 690;
}
.feature-row p {
margin-top: 0.18rem;
color: var(--nodedc-text-muted);
font-size: 0.64rem;
line-height: 1.4;
}
.capability-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.capability-summary > div {
display: flex;
min-height: 4.4rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-radius: 1rem;
background: var(--station-panel);
padding: 0.85rem 1rem;
}
.capability-summary span {
color: var(--nodedc-text-muted);
font-size: 0.66rem;
}
.capability-summary strong {
color: var(--nodedc-text-primary);
font-size: 1.25rem;
font-weight: 650;
}
.overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(19rem, 0.75fr);
gap: 0.85rem;
}
.contour-panel,
.next-actions-panel,
.timeline-panel,
.mission-sequence,
.mission-summary,
.map-inspector {
background: var(--station-panel);
}
.pipeline-strip {
display: grid;
min-width: 0;
grid-template-columns: repeat(7, auto);
align-items: center;
justify-content: stretch;
gap: 0.5rem;
margin-top: 1.3rem;
}
.pipeline-strip > div {
display: grid;
min-width: 0;
min-height: 6rem;
align-content: center;
gap: 0.35rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.034);
padding: 0.8rem;
}
.pipeline-strip > div[data-state="ready"] {
background: var(--station-success-soft);
}
.pipeline-strip > div[data-state="contract"] {
background: var(--station-warning-soft);
}
.pipeline-strip > svg {
color: var(--nodedc-text-muted);
}
.pipeline-strip span,
.pipeline-strip small {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.pipeline-strip strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-list {
display: grid;
gap: 0.46rem;
margin-top: 1rem;
}
.action-list > button {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
border: 0;
border-radius: 1rem;
outline: 0;
background: rgb(255 255 255 / 0.035);
padding: 0.65rem;
text-align: left;
cursor: pointer;
}
.action-list > button:hover,
.action-list > button:focus-visible {
background: rgb(255 255 255 / 0.07);
}
.action-list > button > span {
display: grid;
width: 2.5rem;
height: 2.5rem;
place-items: center;
border-radius: 50%;
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
}
.action-list strong,
.action-list small {
display: block;
}
.action-list strong {
color: var(--nodedc-text-primary);
font-size: 0.7rem;
}
.action-list small {
margin-top: 0.18rem;
color: var(--nodedc-text-muted);
font-size: 0.6rem;
}
.action-list > button > svg {
color: var(--nodedc-text-muted);
}
.camera-grid {
display: grid;
min-height: 33rem;
grid-template-columns: minmax(0, 1.55fr) minmax(18rem, 0.72fr);
grid-template-rows: repeat(3, minmax(9rem, 1fr));
gap: 0.75rem;
}
.camera-slot {
position: relative;
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
border-radius: 1rem;
background: #090a0c;
box-shadow: inset 0 0 0 1px var(--station-hairline);
}
.camera-slot[data-primary="true"] {
grid-row: 1 / 4;
}
.camera-slot__grid {
position: absolute;
inset: 0;
opacity: 0.22;
background-image:
linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px);
background-size: 3rem 3rem;
mask-image: radial-gradient(circle at center, #000, transparent 77%);
}
.camera-slot header,
.camera-slot footer,
.camera-slot__empty {
position: relative;
z-index: 1;
}
.camera-slot header,
.camera-slot footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding: 0.75rem;
}
.camera-slot header > span {
color: var(--nodedc-text-primary);
font-size: 0.68rem;
font-weight: 720;
}
.camera-slot__empty {
display: grid;
place-items: center;
align-content: center;
gap: 0.6rem;
color: var(--nodedc-text-muted);
}
.camera-slot__empty span {
font-size: 0.64rem;
}
.camera-slot footer {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.camera-slot footer button {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 0.04);
color: var(--nodedc-text-muted);
}
.map-layout {
display: grid;
min-height: 34rem;
grid-template-columns: minmax(0, 1fr) 18rem;
gap: 0.75rem;
}
.map-canvas {
position: relative;
min-width: 0;
min-height: 30rem;
overflow: hidden;
border-radius: 1rem;
background:
radial-gradient(circle at 72% 38%, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 28%),
#0a0b0d;
box-shadow: inset 0 0 0 1px var(--station-hairline);
}
.map-canvas__grid,
.map-canvas__contours {
position: absolute;
inset: 0;
}
.map-canvas__grid {
opacity: 0.45;
background-image:
linear-gradient(rgb(255 255 255 / 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.045) 1px, transparent 1px);
background-size: 2.5rem 2.5rem;
}
.map-canvas__contours span {
position: absolute;
border: 1px solid rgb(255 255 255 / 0.055);
border-radius: 48% 52% 62% 38%;
transform: rotate(-12deg);
}
.map-canvas__contours span:nth-child(1) { inset: 16% 14% 20% 19%; }
.map-canvas__contours span:nth-child(2) { inset: 23% 21% 27% 27%; }
.map-canvas__contours span:nth-child(3) { inset: 31% 29% 35% 34%; }
.map-canvas__contours span:nth-child(4) { inset: 39% 37% 43% 42%; }
.map-canvas svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.map-canvas svg path {
fill: none;
stroke: rgb(var(--nodedc-accent-rgb));
stroke-linecap: round;
stroke-width: 3;
filter: drop-shadow(0 0 8px rgb(var(--nodedc-accent-rgb) / 0.34));
}
.map-canvas svg circle {
fill: #0a0b0d;
stroke: rgb(var(--nodedc-accent-rgb));
stroke-width: 4;
}
.map-point-label {
position: absolute;
z-index: 2;
border-radius: var(--nodedc-radius-circle);
background: rgb(8 9 11 / 0.86);
color: var(--nodedc-text-secondary);
padding: 0.34rem 0.55rem;
font-size: 0.58rem;
backdrop-filter: blur(10px);
}
.map-canvas__notice {
position: absolute;
right: 1rem;
bottom: 1rem;
left: 1rem;
display: flex;
align-items: center;
gap: 0.65rem;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
}
.map-inspector h3 {
margin: 0.52rem 0 1rem;
font-size: 1.05rem;
}
.layer-summary-list {
display: grid;
gap: 0.42rem;
margin-bottom: 1rem;
}
.layer-summary-list > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.15rem 0.55rem;
border-radius: 0.8rem;
background: rgb(255 255 255 / 0.035);
padding: 0.7rem;
}
.layer-summary-list > div > span {
width: 0.55rem;
height: 0.55rem;
grid-row: 1 / 3;
align-self: center;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.layer-summary-list > div > span[data-tone="cyan"] { background: #35d7c1; }
.layer-summary-list > div > span[data-tone="violet"] { background: #9a72ff; }
.layer-summary-list > div > span[data-tone="green"] { background: #a6dc74; }
.layer-summary-list > div > span[data-tone="orange"] { background: #f2af4c; }
.layer-summary-list strong { font-size: 0.68rem; }
.layer-summary-list small { color: var(--nodedc-text-muted); font-size: 0.58rem; }
.timeline-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.timeline-panel__head h3 {
margin: 0.48rem 0 0;
font-size: 1.05rem;
}
.timeline-panel__controls {
display: flex;
align-items: center;
gap: 0.65rem;
}
.timeline-panel__controls code {
color: var(--nodedc-text-muted);
font-size: 0.62rem;
}
.timeline-ruler {
display: grid;
grid-template-columns: repeat(11, minmax(0, 1fr));
margin: 1.4rem 0 0.55rem 8.5rem;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.timeline-ruler span {
border-left: 1px solid var(--station-hairline);
padding-left: 0.2rem;
}
.timeline-lanes {
display: grid;
}
.timeline-lane {
display: grid;
min-width: 0;
grid-template-columns: 7.5rem minmax(0, 1fr) 10rem;
align-items: center;
gap: 1rem;
border-top: 1px solid var(--station-hairline);
padding: 0.82rem 0;
}
.timeline-lane strong,
.timeline-lane small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-lane strong { font-size: 0.67rem; }
.timeline-lane small { color: var(--nodedc-text-muted); font-size: 0.59rem; text-align: right; }
.timeline-lane > div {
position: relative;
height: 0.28rem;
border-radius: 999px;
background: rgb(255 255 255 / 0.045);
}
.timeline-lane > div > span {
position: absolute;
top: 50%;
display: none;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: rgb(var(--nodedc-accent-rgb));
box-shadow: 0 0 0 0.26rem var(--station-accent-soft);
transform: translate(-50%, -50%);
}
.timeline-lane[data-active="true"] > div > span { display: block; }
.mission-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(19rem, 0.65fr);
gap: 0.85rem;
}
.mission-steps {
display: grid;
gap: 0.45rem;
margin-top: 1.1rem;
}
.mission-steps button {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
border: 0;
border-radius: 1rem;
background: rgb(255 255 255 / 0.035);
padding: 0.65rem;
text-align: left;
opacity: 1;
}
.mission-steps button > span {
display: grid;
width: 2.5rem;
height: 2.5rem;
place-items: center;
border-radius: 50%;
background: rgb(255 255 255 / 0.05);
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.mission-steps strong,
.mission-steps small { display: block; }
.mission-steps strong { color: var(--nodedc-text-primary); font-size: 0.7rem; }
.mission-steps small { margin-top: 0.18rem; color: var(--nodedc-text-muted); font-size: 0.6rem; }
.mission-steps svg { color: var(--nodedc-text-muted); }
.mission-summary {
align-self: stretch;
}
.mission-readiness {
display: flex;
align-items: baseline;
gap: 0.35rem;
margin-top: 1rem;
}
.mission-readiness strong {
font-size: 4rem;
font-weight: 580;
letter-spacing: -0.08em;
line-height: 1;
}
.mission-readiness span,
.mission-summary p {
color: var(--nodedc-text-muted);
font-size: 0.66rem;
}
.mission-summary p {
line-height: 1.5;
}
.mission-summary__checks {
display: grid;
gap: 0.45rem;
margin: 1rem 0;
}
.mission-summary__checks span {
display: flex;
align-items: center;
gap: 0.55rem;
color: var(--nodedc-text-secondary);
font-size: 0.67rem;
}
.mission-summary__checks i {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}

View File

@ -10,17 +10,19 @@ import {
type LiveRequest,
type ReplayRequest,
} from "./api";
import { localizeRuntimeMessage } from "./messages";
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop";
function messageFor(error: unknown): string {
if (error instanceof ApiError) {
const message = localizeRuntimeMessage(error.message) ?? error.message;
return error.status
? `${error.message} (HTTP ${error.status})`
: error.message;
? `${message} (HTTP ${error.status})`
: message;
}
return "Запрос к локальному API K1 завершился ошибкой.";
return "Запрос к локальному сервису устройства завершился ошибкой.";
}
function measuredLatency(state: ConsoleState | null): number | null {

View File

@ -0,0 +1,541 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Button,
Checker,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} 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";
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,
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 ? "accent" : "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 DeviceWorkspace({ console }: { console: ConsoleController }) {
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 metrics = state?.metrics;
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 = () => {
const host = liveHost.trim();
void startLive(host ? { host } : {});
};
const submitReplay = () => {
const speed = Number(replaySpeed);
void startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
};
return (
<div className="device-workspace">
{error ? (
<aside className="error-banner" role="alert">
<Icon name="alert" size={18} />
<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>Подключение первого устройства</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 → публикация предпросмотра"
/>
<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">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</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"}
>
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
description="Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия."
onChange={setPowerConfirmed}
/>
</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="Передайте настройки WiFi"
status={state?.k1_ip ? "Подключено" : "Не подключено"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Название сети WiFi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
/>
<TextField
label="Пароль WiFi"
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="accent"
icon={<Icon name="network" />}
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к WiFi"}
</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" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</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 || "Сначала подключите устройство к WiFi"}
/>
<Button
variant="accent"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
onClick={submitLive}
>
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
: "Сначала подключите устройство к WiFi или укажите его локальный адрес."}
</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)}
/>
<Checker
checked={replayLoop}
label="Повторять по кругу"
description="После последнего кадра начать запись заново."
onChange={setReplayLoop}
/>
<Button
variant="accent"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
onClick={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>
);
}

View File

@ -0,0 +1,587 @@
import { useCallback, useMemo, useState } from "react";
import {
Button,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
} from "@nodedc/ui-react";
import type { ConsoleState } from "../api";
import { MetricCard } from "../components/MetricCard";
import { localizeRuntimeMessage } from "../messages";
import {
RerunViewport,
type RerunSelection,
type RerunViewportStatus,
} from "../components/RerunViewport";
import {
capabilityStatusLabel,
type CapabilityStatus,
type WorkspaceDefinition,
} from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneProjection, SceneSettings } from "../sceneSettings";
import type { BackendStatus } from "../useK1Console";
const projectionItems = [
{ value: "3d", label: "3D" },
{ value: "2d", label: "2D" },
{ value: "map", label: "Карта" },
] satisfies Array<{ value: SceneProjection; label: string }>;
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
if (status === "ready") return "accent";
if (status === "contract") return "warning";
return "neutral";
}
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
return (
<div className="feature-inventory">
{definition.groups.map((group) => (
<section className="feature-group" key={group.title}>
<header>
<span className="section-eyebrow">ФУНКЦИОНАЛЬНЫЙ БЛОК</span>
<h3>{group.title}</h3>
<p>{group.description}</p>
</header>
<div className="feature-list">
{group.capabilities.map((capability) => (
<div className="feature-row" key={capability.label} data-status={capability.status}>
<span className="feature-row__mark" aria-hidden="true" />
<div>
<strong>{capability.label}</strong>
<p>{capability.description}</p>
</div>
<StatusBadge tone={statusTone(capability.status)}>
{capabilityStatusLabel[capability.status]}
</StatusBadge>
</div>
))}
</div>
</section>
))}
</div>
);
}
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
return (
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>{definition.description}</p>
</div>
{note ? <span className="workspace-lead__note">{note}</span> : null}
</section>
);
}
export interface WorkspaceNavigation {
openView: (viewId: string) => void;
openSource: () => void;
openDisplay: () => void;
openLayers: () => void;
}
export interface WorkspaceRendererProps {
definition: WorkspaceDefinition;
state: ConsoleState | null;
backendStatus: BackendStatus;
sourceUrl: string;
sceneSettings: SceneSettings;
onSceneSettingsChange: (settings: SceneSettings) => void;
navigation: WorkspaceNavigation;
}
function OverviewWorkspace({
definition,
state,
backendStatus,
navigation,
}: WorkspaceRendererProps) {
const metrics = state?.metrics;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const adapterOnline = backendStatus === "online";
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
return (
<div className="standard-workspace overview-workspace">
<WorkspaceLead
definition={definition}
note="Числа появляются только из реального локального контура"
/>
<section className="metrics-grid" aria-label="Оперативные показатели">
<MetricCard
featured
eyebrow="ЗАДЕРЖКА"
value={formatNumber(latency)}
unit="мс"
detail="Локальный путь приёма и публикации"
/>
<MetricCard
eyebrow="ЧАСТОТА"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение потока"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Без синтетического заполнения"
/>
<MetricCard
eyebrow="РЕЖИМ"
value={sourceModeLabel(state?.source_mode)}
detail="Реальное время, повтор или ожидание"
/>
</section>
<div className="overview-grid">
<GlassSurface className="contour-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">КРИТИЧЕСКИЙ ПУТЬ</span>
<h2>Контур наблюдения</h2>
</div>
<StatusBadge tone={adapterOnline ? "success" : "danger"}>
{adapterOnline ? "Контур доступен" : "Нет связи"}
</StatusBadge>
</header>
<div className="pipeline-strip" aria-label="Путь данных">
<div data-state={state?.k1_ip ? "ready" : "idle"}>
<span>01</span>
<strong>Устройство</strong>
<small>{state?.k1_ip || "не назначено"}</small>
</div>
<Icon name="chevron-right" />
<div data-state={streamActive ? "ready" : "idle"}>
<span>02</span>
<strong>Адаптер</strong>
<small>{sourceModeLabel(state?.source_mode)}</small>
</div>
<Icon name="chevron-right" />
<div data-state="contract">
<span>03</span>
<strong>Rerun</strong>
<small>ожидает источник</small>
</div>
<Icon name="chevron-right" />
<div data-state="ready">
<span>04</span>
<strong>Оператор</strong>
<small>интерфейс готов</small>
</div>
</div>
</GlassSurface>
<GlassSurface className="next-actions-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">БЫСТРЫЙ ПЕРЕХОД</span>
<h2>Продолжить работу</h2>
</div>
</header>
<div className="action-list">
<button type="button" onClick={() => navigation.openView("local-device")}>
<span><Icon name="network" /></span>
<div>
<strong>Подключить устройство</strong>
<small>Bluetooth, WiFi и запуск потока</small>
</div>
<Icon name="chevron-right" />
</button>
<button type="button" onClick={() => navigation.openView("spatial-scene")}>
<span><Icon name="globe" /></span>
<div>
<strong>Открыть пространственную сцену</strong>
<small>Облако точек, траектория и слои</small>
</div>
<Icon name="chevron-right" />
</button>
<button type="button" onClick={() => navigation.openView("streams")}>
<span><Icon name="activity" /></span>
<div>
<strong>Проверить потоки</strong>
<small>Транспорт, схемы и готовность</small>
</div>
<Icon name="chevron-right" />
</button>
</div>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
return (
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
<div className="empty-spatial-stage__grid" aria-hidden="true" />
<div className="spatial-axis" aria-hidden="true">
<span data-axis="x">X</span>
<span data-axis="y">Y</span>
<span data-axis="z">Z</span>
</div>
<div className="empty-spatial-stage__message">
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
<strong>Визуальный источник не назначен</strong>
<p>
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
открыть реальные данные.
</p>
</div>
</div>
);
}
function SpatialWorkspace({
state,
sourceUrl,
sceneSettings,
onSceneSettingsChange,
navigation,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const metrics = state?.metrics;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
setViewerMessage(message ?? "");
}, []);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const viewerStatusLabel = {
idle: "Источник не назначен",
loading: "Подключение",
ready: "Визуализатор готов",
error: "Ошибка источника",
}[viewerStatus];
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
return (
<div className="spatial-workspace">
<div className="spatial-toolbar">
<div className="spatial-toolbar__mode">
<SegmentedControl
label="Проекция сцены"
value={sceneSettings.projection}
items={projectionItems}
onChange={(projection) => onSceneSettingsChange({ ...sceneSettings, projection })}
/>
</div>
<div className="spatial-toolbar__actions">
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
Источник
</Button>
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
Слои
</Button>
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
Отображение
</Button>
</div>
</div>
<div className="spatial-viewport-shell">
{sourceUrl.trim() ? (
<RerunViewport
sourceUrl={sourceUrl}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
/>
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
<div className="scene-status scene-status--top-left">
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
{viewerMessage ? <small>{viewerMessage}</small> : null}
</div>
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
<div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
</div>
<div>
<span>Точек</span>
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div>
<div>
<span>Задержка</span>
<strong>{formatNumber(latency)}<small> мс</small></strong>
</div>
</div>
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
но Rerun-адаптер пока не назначен.
</span>
</div>
) : null}
{selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
{selection.viewName ? <small>{selection.viewName}</small> : null}
</div>
) : null}
<div className="scene-timeline">
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
<Icon name="chevron-left" />
</Button>
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
Воспроизвести
</Button>
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
</div>
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
ЭФИР
</span>
</div>
</div>
<div className="spatial-contract-strip">
<span><i data-state="ready" />Облако точек</span>
<span><i data-state="ready" />Траектория</span>
<span><i data-state="contract" />Преобразования</span>
<span><i data-state="contract" />Камеры в 3D</span>
<span><i data-state="contract" />Объекты / маски</span>
<span><i data-state="contract" />Компоновка</span>
</div>
</div>
);
}
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
return (
<div className="standard-workspace cameras-workspace">
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
<div className="camera-grid">
{slots.map((slot, index) => (
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
<div className="camera-slot__grid" aria-hidden="true" />
<header>
<span>{slot}</span>
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
</header>
<div className="camera-slot__empty">
<Icon name={index === 3 ? "image" : "video"} />
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
</div>
<footer>
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
</footer>
</div>
))}
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function MapWorkspace({ definition }: WorkspaceRendererProps) {
const isMissionRoute = definition.id === "routes";
return (
<div className="standard-workspace map-workspace">
<WorkspaceLead
definition={definition}
note={isMissionRoute ? "Схема интерфейса — координаты и командный канал не подключены" : "Картографический модуль не подключён"}
/>
<div className="map-layout">
<div className="map-canvas" data-route={isMissionRoute ? "true" : undefined}>
<div className="map-canvas__grid" aria-hidden="true" />
<div className="map-canvas__contours" aria-hidden="true">
<span /><span /><span /><span />
</div>
<svg viewBox="0 0 1000 560" role="img" aria-label="Схематический черновик маршрута без географических координат">
<path d="M165 420 C 235 350, 272 375, 340 292 S 480 235, 545 290 S 680 340, 765 210 S 845 125, 892 155" />
<circle cx="165" cy="420" r="8" />
<circle cx="340" cy="292" r="8" />
<circle cx="545" cy="290" r="8" />
<circle cx="765" cy="210" r="8" />
<circle cx="892" cy="155" r="8" />
</svg>
<div className="map-point-label" style={{ left: "15%", top: "72%" }}>A · старт</div>
<div className="map-point-label" style={{ left: "52%", top: "49%" }}>C · наблюдение</div>
<div className="map-point-label" style={{ left: "84%", top: "22%" }}>E · финиш</div>
<div className="map-canvas__notice">
<StatusBadge tone="warning">Схема интерфейса</StatusBadge>
<span>Не является картой местности и не содержит реальных координат.</span>
</div>
</div>
<GlassSurface className="map-inspector" padding="lg">
<span className="section-eyebrow">СЛОИ КАРТЫ</span>
<h3>Рабочая композиция</h3>
<div className="layer-summary-list">
<div><span data-tone="cyan" /><strong>Маршрут</strong><small>5 точек · схема</small></div>
<div><span data-tone="violet" /><strong>Геозоны</strong><small>не назначены</small></div>
<div><span data-tone="green" /><strong>Высота</strong><small>контракт GridMap</small></div>
<div><span data-tone="orange" /><strong>Проходимость</strong><small>обработчик не подключён</small></div>
</div>
<Button variant="secondary" width="full" disabled>Редактировать точки</Button>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
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: "каналы не назначены", active: false },
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) },
], [state]);
return (
<div className="standard-workspace timeline-workspace">
<WorkspaceLead definition={definition} note="Отображаются только доступные состояния" />
<GlassSurface className="timeline-panel" padding="lg">
<header className="timeline-panel__head">
<div>
<span className="section-eyebrow">СИНХРОННЫЕ КАНАЛЫ</span>
<h3>Текущий интервал</h3>
</div>
<div className="timeline-panel__controls">
<Button size="compact" variant="secondary" disabled>Воспроизвести</Button>
<code>::.</code>
</div>
</header>
<div className="timeline-ruler" aria-hidden="true">
{Array.from({ length: 11 }, (_, index) => <span key={index}>{index * 10}</span>)}
</div>
<div className="timeline-lanes">
{rows.map((row, index) => (
<div className="timeline-lane" key={row.label} data-active={row.active ? "true" : undefined}>
<strong>{row.label}</strong>
<div><span style={{ left: row.active ? `${18 + index * 9}%` : "0" }} /></div>
<small>{row.value}</small>
</div>
))}
</div>
</GlassSurface>
<FeatureInventory definition={definition} />
</div>
);
}
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
const steps = [
{ id: "01", label: "Аппарат", value: "Не назначен" },
{ id: "02", label: "Зона", value: "Не задана" },
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
{ id: "04", label: "Наблюдение", value: "Облако точек" },
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
];
return (
<div className="standard-workspace mission-workspace">
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
<div className="mission-layout">
<GlassSurface className="mission-sequence" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
<h2>Новая миссия</h2>
</div>
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
</header>
<div className="mission-steps">
{steps.map((step) => (
<button key={step.id} type="button" disabled>
<span>{step.id}</span>
<div><strong>{step.label}</strong><small>{step.value}</small></div>
<Icon name="chevron-right" />
</button>
))}
</div>
</GlassSurface>
<GlassSurface className="mission-summary" padding="lg">
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
<div className="mission-summary__checks">
<span><i />Аппарат</span>
<span><i />Геометрия</span>
<span><i />Связь</span>
<span><i />Безопасность</span>
</div>
<Button variant="accent" width="full" disabled>Сохранить миссию</Button>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
const active = definition.groups.reduce(
(count, group) => count + group.capabilities.filter((capability) => capability.status === "active").length,
0,
);
const ready = definition.groups.reduce(
(count, group) => count + group.capabilities.filter((capability) => capability.status === "ready").length,
0,
);
return (
<div className="standard-workspace catalog-workspace">
<WorkspaceLead definition={definition} note="Архитектурный и интерфейсный контракт" />
<section className="capability-summary" aria-label="Готовность функционала">
<div><span>Всего функций</span><strong>{total}</strong></div>
<div><span>Работает сейчас</span><strong>{active}</strong></div>
<div><span>Готово к данным</span><strong>{ready}</strong></div>
<div><span>Без серверной логики</span><strong>{Math.max(0, total - active - ready)}</strong></div>
</section>
<FeatureInventory definition={definition} />
</div>
);
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
switch (props.definition.kind) {
case "overview":
return <OverviewWorkspace {...props} />;
case "spatial":
return <SpatialWorkspace {...props} />;
case "cameras":
return <CamerasWorkspace {...props} />;
case "map":
return <MapWorkspace {...props} />;
case "timeline":
return <TimelineWorkspace {...props} />;
case "missions":
return <MissionWorkspace {...props} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "device":
return null;
}
}

View File

@ -1,12 +1,16 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import wasm from "vite-plugin-wasm";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
return {
plugins: [react()],
plugins: [react(), wasm()],
build: {
target: "esnext",
},
server: {
host: "127.0.0.1",
port: 5173,

View File

@ -45,7 +45,7 @@ class VisualizationRuntime:
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._phase: RuntimePhase = "idle"
self._message = "Готово. Включите K1 и начните с поиска по Bluetooth."
self._message = "Готово. Включите устройство и начните с поиска по Bluetooth."
self._source_mode: SourceMode = "idle"
self._foxglove_ws_url: str | None = None
self._foxglove_viewer_url: str | None = None
@ -65,7 +65,7 @@ class VisualizationRuntime:
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise ValueError("файл записи не найден на этом MacBook")
raise ValueError("файл записи не найден на этом компьютере")
if not math.isfinite(speed) or speed < 0:
raise ValueError("скорость повтора должна быть неотрицательным числом")
# Validate the reviewed shape before changing runtime state.
@ -96,7 +96,7 @@ class VisualizationRuntime:
self._start(
source_mode="live",
phase="starting_live",
message="Запускаем приём MQTT и мост Foxglove.",
message="Запускаем приём MQTT и локальный мост визуализации.",
target=lambda: self._run_live(
host,
out_dir.expanduser().resolve(),
@ -199,7 +199,8 @@ class VisualizationRuntime:
out_dir / "captures" / "mqtt_live",
duration_seconds=duration_seconds,
on_ready=lambda: self._set_running(
"live", "Приём запущен. Теперь дважды нажмите физическую кнопку K1."
"live",
"Приём запущен. Теперь дважды нажмите физическую кнопку устройства.",
),
on_message_recorded=on_message,
should_stop=self._stop_event.is_set,
@ -248,7 +249,9 @@ class VisualizationRuntime:
self._foxglove_viewer_url = bridge.viewer_url
if self._phase != "stopping":
self._phase = running_phase
self._message = "Мост Foxglove готов; источник данных запущен."
self._message = (
"Локальный мост визуализации готов; источник данных запущен."
)
publisher_ready.set()
self._notify()
while not source_done.is_set() or not messages.empty():
@ -275,13 +278,14 @@ class VisualizationRuntime:
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True)
publisher.start()
if not publisher_ready.wait(timeout=15.0):
self._finish_error("Мост Foxglove не запустился за 15 секунд.")
self._finish_error("Локальный мост визуализации не запустился за 15 секунд.")
source_done.set()
self._stop_event.set()
return
if publisher_error:
self._finish_error(
f"Ошибка моста Foxglove: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
"Ошибка локального моста визуализации: "
f"{type(publisher_error[0]).__name__}: {publisher_error[0]}"
)
source_done.set()
return

View File

@ -80,13 +80,13 @@ class ConsoleService:
message = runtime["message"]
elif selected_device_id is not None:
phase = "device_selected"
message = "K1 выбран. Теперь введите название и пароль Wi-Fi."
message = "Устройство выбрано. Теперь введите название и пароль Wi-Fi."
elif devices:
likely_count = sum(bool(item.get("likely_k1")) for item in devices)
phase = "idle"
message = (
f"Найдено BLE-устройств: {len(devices)}. "
f"Возможных K1: {likely_count}. Выберите нужное устройство."
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
)
else:
phase = "idle"
@ -144,7 +144,7 @@ class ConsoleService:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
self._set_operation(
"provisioning",
"Передаём в K1 настройки Wi-Fi одним подтверждённым запросом.",
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
)
session_dir = _new_operation_session_dir(
self.repository_root,
@ -177,12 +177,14 @@ class ConsoleService:
)
if ipv4 is None:
raise RuntimeError(
"K1 не сообщил адрес в локальной сети; автоматического повтора не было"
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
)
with self._lock:
self._selected_device_id = request.device_id
self._k1_ip = ipv4
self._operation_message = "K1 подключён к Wi-Fi и сообщил локальный адрес."
self._operation_message = (
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
)
finally:
password = ""
with self._lock:
@ -193,7 +195,7 @@ class ConsoleService:
target = host or self.state()["k1_ip"]
if not isinstance(target, str) or not target:
raise ValueError(
"сначала подключите K1 к Wi-Fi или укажите его локальный адрес"
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
)
target = validate_private_ipv4(target)
out_dir = new_live_session_dir(self.repository_root)
@ -219,7 +221,7 @@ class ConsoleService:
service = ConsoleService(REPOSITORY_ROOT)
app = FastAPI(
title="NODE.DC K1 Live Console API",
title="NODE.DC Device Control API",
version=__version__,
docs_url="/api/docs",
redoc_url=None,
@ -257,7 +259,7 @@ async def connect(request: ConnectRequest) -> dict[str, Any]:
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
raise HTTPException(
status_code=502,
detail=f"Ошибка подключения K1 к Wi-Fi: {exc}",
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
) from exc