Compare commits

...

2 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 904c6ea13a feat: activate self-hosted lidar spatial scene 2026-07-16 10:36:51 +03:00
DCCONSTRUCTIONS 6402601d6d feat: build NODE.DC control station shell 2026-07-16 01:20:09 +03:00
37 changed files with 6756 additions and 1889 deletions

View File

@ -7,8 +7,10 @@ or speculative writes.
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured
the scan-correlated point-cloud and pose streams, and decoded both successfully. the scan-correlated point-cloud and pose streams, and decoded both successfully.
The repository also contains a local React control console and a Foxglove live The repository also contains a local React control console, an automatic Rerun
bridge for the verified point-cloud and trajectory topics. gRPC bridge for the verified point-cloud and trajectory streams, and a
self-hosted Rerun Web Viewer embedded in that console. The former Foxglove
bridge remains only as a legacy regression module.
The repository now contains one narrowly gated state-changing command: The repository now contains one narrowly gated state-changing command:
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning `ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
@ -55,45 +57,77 @@ uv run k1link doctor
uv run pytest 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 browser application is now a universal NODE.DC Control Station rather than
the repository root: 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 ```bash
uv sync --group dev
cd apps/k1-viewer cd apps/k1-viewer
npm install npm install
npm run typecheck
npm run build npm run build
cd ../.. cd ../..
uv run k1link serve uv run k1link serve
``` ```
Open `http://127.0.0.1:8000`. The API, credential form and Foxglove WebSocket Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
bind to loopback only. The console supports: 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; The Observation spatial workspace embeds the open-source Rerun Web Viewer
- one operator-triggered reviewed BLE Wi-Fi provisioning write; inside the NODE.DC shell. It can open a compatible RRD file over HTTP(S) or a
- live read-only MQTT capture with raw-first evidence storage; Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
- replay of native `.k1mqtt` evidence and the reviewed four-column TSV capture; use an external hosted viewer UI.
- `/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.
For live mode, start the session in the console and use the verified physical The first K1 live session or replay in a `k1link serve` process creates one local
double-click on K1 to start or stop scanning. The connector deliberately does Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
not publish modeling commands yet. For the recorded proof, replay this ignored the resulting URL through control-plane state. Later sessions reset their
local file at `1x` with loop enabled: session-local scene and metrics and reuse that process-wide stream; this avoids
restarting the native listener while the embedded browser remains connected.
Unless an operator has entered a manual source, the React application assigns
that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first evidence
capture → bounded latest-wins preview queue → reviewed protobuf/LZ4 decoders →
Rerun `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer.
```text The default Rerun blueprint shows a 12-second sliding accumulation of real point
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv frames. Product controls are connected for point size, intensity/height/distance
``` or available RGB coloring, Turbo/Viridis/Plasma/grayscale/custom palettes,
point and trajectory visibility, and the scene grid. Projection, custom
timeline transport and saved layout remain later product work. No synthetic
point cloud, trajectory, camera frame or latency value is generated.
Foxglove remains the full 3D workspace rather than being reimplemented in the A powered-device checkpoint passed 80 real MQTT messages through the current
React console. In its 3D panel, select `/k1/points`, color by `intensity` (or Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last
`z`/`<distance>`) and choose Turbo, Rainbow or a custom gradient. Add cloud and zero decode errors. Raw panoramic camera frames remain absent. Rerun
`/k1/trajectory` for the cyan path and `/k1/pose` for the current pose. See the `capture_time` is the Mac receive timestamp, not a proven K1 sensor timestamp or
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) for timing semantics and current photon-to-screen measurement.
limitations.
The old Foxglove implementation is retained only in
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current
live/replay runtime does not start it or use TCP 8765. The
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
its timing/security boundaries; the frontend contract is documented in
[`apps/k1-viewer/README.md`](apps/k1-viewer/README.md).
The FastAPI application and credential endpoint bind to loopback, but the Rerun
gRPC server currently binds TCP 9876 on all network interfaces even though its
reported source URL contains `127.0.0.1`. It has no connector-level
authentication or TLS. Use it only on a trusted laboratory LAN, do not expose
9876 to the public Internet or a cellular WAN, and add an authenticated secure
proxy before any remote deployment. Stopping acquisition keeps the local scene
server and its URL available for the next session. Stop `k1link serve` to close
the listener and release its retained memory.
`doctor` is intentionally non-invasive. It checks the local Python environment `doctor` is intentionally non-invasive. It checks the local Python environment
and reports external tools; it does not request Bluetooth permission, scan the and reports external tools; it does not request Bluetooth permission, scan the
@ -135,7 +169,7 @@ present.
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md) - [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md) - [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md) - [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
- [Live console and Foxglove runbook](docs/06_K1_LIVE_VIEWER.md) - [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md) - [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
- [Session manifest schema](schemas/session-manifest.schema.json) - [Session manifest schema](schemas/session-manifest.schema.json)
- [Reference input provenance](docs/reference/README.md) - [Reference input provenance](docs/reference/README.md)

View File

@ -1,51 +1,256 @@
# K1 Live Console # Пункт управления NODE.DC
React 19 + TypeScript + Vite frontend for the local K1 bridge. The console handles Универсальный браузерный пункт управления NODE.DC на React 19, TypeScript и
connection setup, source selection, transport status, latency metrics and handoff to Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
the Foxglove 3D viewer. It does not emulate scanner operations or generate placeholder наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
telemetry. device adapter, но структура интерфейса от него не зависит.
## Run locally Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
потока или совместимой RRD-записи.
## Текущее состояние
| Контур | Состояние | Что это означает |
| --- | --- | --- |
| 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 сохранение, декодирование облака точек и позы, реальные метрики. |
| Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. |
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. |
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
Приложение не генерирует демонстрационное облако, траекторию, кадры или
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
показывают `—`.
## Архитектура данных
```text
K1 MQTT :1883, read-only
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
└── bounded latest-wins preview queue (32 сообщения)
└── проверенный protobuf/LZ4 decoder
└── Rerun Points3D + Transform3D + LineStrips3D
└── gRPC/proxy TCP 9876
└── rerun_grpc_url в REST/WebSocket state
└── встроенный @rerun-io/web-viewer
└── пространственная сцена NODE.DC
React Control Station ←→ REST /api/* + WebSocket /api/events
FastAPI на 127.0.0.1:8000
CoreBluetooth + live/replay runtime
```
В момент готовности `RerunBridge` backend публикует адрес вида
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
если оператор не указал ручной source. После остановки приёма URL и встроенный
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
траекторию и метрики и использует тот же listener. Он закрывается вместе с
процессом `k1link serve`.
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
совместимость со старым контрактом, но текущий runtime держит их пустыми.
## Фиксированная оболочка
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. Размер точек, способ окрашивания и палитра, 12-секундное по
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение
layout пока остаются интерфейсным контрактом.
## Архитектурные разделы
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
страницы.
| Раздел | Назначение |
| --- | --- |
| Центр | Оперативный обзор, состояние контура и активность оператора. |
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
как утверждение, что для каждой карточки уже существует backend.
## Установка, проверка, сборка и запуск
Требуются Node.js 20.19+ либо 22.12+, `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 ```bash
cd apps/k1-viewer cd apps/k1-viewer
npm install
npm run dev npm run dev
``` ```
The development server listens on `http://127.0.0.1:5173` and proxies `/api` to Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на
`http://127.0.0.1:8000`. Set `VITE_API_TARGET` in a local `.env` file to use a `http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
different backend address. Production artifacts are built with `npm run build`. `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` ## Операторский путь для текущего K1 adapter
checkout through `file:` dependencies. Their source is not copied into this app.
## 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. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
state. Ручной source вводить не требуется.
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
частота, число точек, задержка и пропуски preview появятся после прихода
сообщений K1.
| Method | Route | Body / purpose | В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
0 ошибок декодирования.
## Источники Rerun
Live/replay runtime автоматически назначает первый 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 хранится только в состоянии текущей страницы и имеет приоритет над
автоматическим source;
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
оболочку. Backend принимает и применяет к Rerun размер точки `0.512.0`, режимы
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
Plasma, grayscale и custom, накопление `0120` секунд, а также видимость облака,
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
а не готовую семантическую классификацию.
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
в памяти текущей страницы и не записывается на диск.
## Контракт локального API
| Метод | Route | Тело / назначение |
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/api/health` | Local service health | | `GET` | `/api/health` | Проверка локального сервиса. |
| `GET` | `/api/state` | Authoritative console state | | `GET` | `/api/state` | Авторитетный snapshot состояния. |
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }` | | `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. |
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }` | | `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. |
| `POST` | `/api/session/live` | Optional `{ "host", "duration_seconds" }` | | `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }` | | `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
| `POST` | `/api/session/stop` | Stop the active source | | `POST` | `/api/session/stop` | Остановка активного источника. |
| `WS` | `/api/events` | State snapshots for live UI updates | | `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
| `WS` | `/api/events` | Периодические snapshots для live UI. |
`/api/state` and state-changing responses may return the snapshot directly or as `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
`{ "state": { ... } }`. A snapshot exposes `phase`, `message`, `devices`, как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `foxglove_ws_url`, `foxglove_viewer_url`, `selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
`source_mode` and `metrics`. `viewer_settings`. 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 ## Карта исходников frontend
cleared after the backend acknowledges a successful connection. It is never written to
local storage or included in a URL.
## 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. ## Safety и чувствительные данные
- BLE scanning remains a real API action and reports HTTP/network errors visibly.
- The Foxglove button is disabled until the backend supplies - Control Station и credential endpoint доступны только на loopback.
`foxglove_viewer_url`. - Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
- Replay cannot start without a backend-local capture path. хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет
connector-level authentication или TLS. Этот порт допустим только в доверенной
лабораторной LAN; его нельзя пробрасывать в публичный Интернет или cellular
WAN без аутентифицированного TLS reverse proxy. Listener намеренно остаётся
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
очищается после успешного ответа и не сохраняется в URL/local storage.
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
случайные GATT writes и автоматические повторы запрещены.
- MQTT live/replay не публикует команды устройству. Запуск и остановка
физического сканирования остаются за кнопкой K1.
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
перегрузке preview может быть отброшен, raw evidence сохраняется.
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
накопления viewer использует session-local `stream_time`. `capture_time` — не
доказанный timestamp сенсора K1 и не photon-to-screen latency.
- Панорамный камерный поток в наблюдавшихся MQTT report topics отсутствует;
текущая Rerun-сцена содержит только облако точек и позу/траекторию, а
операционные метрики отображаются внешней оболочкой Control Station.
- `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
траекторию и карту помещения. В репозиторий попадают только redacted manifests
и безопасная документация.

View File

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

View File

@ -11,6 +11,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },
@ -20,7 +21,8 @@
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^7.0.0" "vite": "^7.0.0",
"vite-plugin-wasm": "^3.6.0"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@ -844,6 +846,12 @@
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"link": true "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "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/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },
@ -22,7 +23,8 @@
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^7.0.0" "vite": "^7.0.0",
"vite-plugin-wasm": "^3.6.0"
}, },
"engines": { "engines": {
"node": ">=20" "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

@ -30,10 +30,23 @@ export interface ConsoleState {
k1_ip?: string | null; k1_ip?: string | null;
foxglove_ws_url?: string | null; foxglove_ws_url?: string | null;
foxglove_viewer_url?: string | null; foxglove_viewer_url?: string | null;
rerun_grpc_url?: string | null;
viewer_settings?: ViewerSettings | null;
source_mode?: SourceMode | null; source_mode?: SourceMode | null;
metrics?: K1Metrics; metrics?: K1Metrics;
} }
export interface ViewerSettings {
point_size: number;
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
}
export interface HealthResponse { export interface HealthResponse {
ok?: boolean; ok?: boolean;
status?: string; status?: string;
@ -99,7 +112,7 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
}, },
}); });
} catch { } catch {
throw new ApiError("Не удалось подключиться к локальному API K1."); throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
} }
const bodyText = await response.text(); const bodyText = await response.text();
@ -119,9 +132,9 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
? body.detail ? body.detail
: typeof body === "string" && body.trim() : typeof body === "string" && body.trim()
? body.trim() ? body.trim()
: `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`; : `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
throw new ApiError( throw new ApiError(
detail || `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`, detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
response.status, response.status,
); );
} }
@ -174,6 +187,10 @@ export const api = {
stopSession(): Promise<ConsoleState> { stopSession(): Promise<ConsoleState> {
return postState("/api/session/stop"); return postState("/api/session/stop");
}, },
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> {
return postState("/api/viewer/settings", body);
},
}; };
export type EventSocketStatus = "connecting" | "open" | "closed" | "error"; export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
@ -203,5 +220,5 @@ export function openEventSocket(
socket.addEventListener("error", () => onStatus("error")); socket.addEventListener("error", () => onStatus("error"));
socket.addEventListener("close", () => onStatus("closed")); socket.addEventListener("close", () => onStatus("closed"));
return () => socket.close(1000, "Консоль K1 закрыта"); return () => socket.close(1000, "Пункт управления закрыт");
} }

View File

@ -0,0 +1,61 @@
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__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="primary" 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,133 @@
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 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 = () => {
// Remove the gRPC receiver explicitly before tearing down WASM. This
// closes the browser-side stream promptly so the local SDK server can
// release its port before the next device session starts.
try {
viewer.close(normalizedSource);
} catch {
// The viewer can already be stopped after a startup failure.
}
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,
);
if (disposed) {
stopViewer();
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(() => {
if (disposed) return;
const message = "Не удалось запустить встроенный визуализатор.";
setStatus("error");
onStatusChange?.("error", message);
});
return () => {
disposed = true;
unsubscribers.forEach((unsubscribe) => unsubscribe());
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"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
throw new Error("K1 console root element is missing."); throw new Error("Не найден корневой элемент пункта управления.");
} }
rootElement.classList.add("nodedc-ui-root"); rootElement.classList.add("nodedc-ui-root");

View File

@ -0,0 +1,30 @@
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;
const localized = runtimeMessageReplacements.reduce(
(localized, [pattern, replacement]) => localized.replace(pattern, replacement),
message,
);
const withoutTechnicalTerms = localized.replace(
/\b(?:API|BLE|Bluetooth|gRPC|IP|JSON|K1MQTT|MQTT|Rerun|RRD|TSV|UUID|Wi-Fi)\b/gi,
"",
);
if (/[A-Za-z]{3,}/.test(withoutTechnicalTerms)) {
return "Операция не выполнена. Технические подробности сохранены в журнале сервера.";
}
return localized;
}

View File

@ -0,0 +1,89 @@
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.publish_ms),
].filter((value): value is number => value !== null);
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
}
export function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("ru-RU", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
export function sourceModeLabel(mode: string | null | undefined): string {
if (mode === "live") return "Реальное время";
if (mode === "replay") return "Повтор записи";
if (mode === "idle") return "Ожидание";
return "Неизвестно";
}

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("Метрики потока в реальном времени", "Время до Rerun SDK, частота кадров, число точек и пропуски предпросмотра."),
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 { @import "./styles/base.css";
background: #050506; @import "./styles/shell.css";
} @import "./styles/workspaces.css";
@import "./styles/spatial.css";
html { @import "./styles/device.css";
min-width: 320px; @import "./styles/responsive.css";
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;
}
}

View File

@ -0,0 +1,388 @@
:root {
background: #050506;
}
html,
body,
#root {
width: 100%;
height: 100%;
min-width: 320px;
min-height: 100%;
overflow: hidden;
background: #050506;
}
body {
margin: 0;
}
#root[data-nodedc-theme="dark"],
body {
--nodedc-accent-rgb: 247 248 244;
--nodedc-on-accent-rgb: 8 8 10;
--nodedc-focus-surface: rgb(255 255 255 / 0.09);
--nodedc-primary-action-bg: rgb(247 248 244 / 0.96);
--nodedc-primary-action-hover: rgb(255 255 255);
--nodedc-primary-action-color: rgb(8 8 10 / 0.96);
--nodedc-primary-action-shadow: none;
--nodedc-modal-surface: rgb(15 15 17 / 0.97);
--nodedc-floating-surface: rgb(15 15 17 / 0.98);
}
button,
input,
textarea,
select {
font: inherit;
}
button {
color: inherit;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.control-station,
body {
--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(255 255 255 / 0.045);
--station-success-soft: rgb(255 255 255 / 0.045);
--station-warning-soft: rgb(255 255 255 / 0.045);
--station-danger-soft: rgb(255 255 255 / 0.045);
}
.nodedc-button[data-variant="accent"] {
--nodedc-button-bg: var(--nodedc-primary-action-bg);
--nodedc-button-color: var(--nodedc-primary-action-color);
}
.nodedc-button[data-variant="accent"]:hover:not(:disabled) {
background: var(--nodedc-primary-action-hover);
}
.nodedc-button[data-variant="danger"] {
color: var(--nodedc-text-primary);
}
.nodedc-button[data-variant="danger"]:hover:not(:disabled) {
background: var(--nodedc-glass-control-hover);
}
.nodedc-status {
min-height: auto;
justify-content: flex-start;
gap: 0.42rem;
border-radius: 0;
background: transparent;
color: var(--nodedc-text-primary);
padding: 0;
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.nodedc-status::before {
width: 0.42rem;
height: 0.42rem;
flex: 0 0 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.nodedc-status[data-tone="success"],
.nodedc-status[data-tone="warning"],
.nodedc-status[data-tone="danger"],
.nodedc-status[data-tone="accent"] {
background: transparent;
color: var(--nodedc-text-primary);
}
.nodedc-status[data-tone="success"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.nodedc-status[data-tone="warning"]::before {
background: rgb(var(--nodedc-warning-rgb));
}
.nodedc-status[data-tone="danger"]::before {
background: rgb(var(--nodedc-danger-rgb));
}
.nodedc-status[data-tone="accent"]::before {
background: var(--nodedc-text-primary);
}
.nodedc-inspector__section-trigger[data-open="true"],
.nodedc-inspector__section-trigger[data-active="true"],
.nodedc-inspector__section-trigger[data-tone="accent"] {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-inspector__section-trigger[data-open="true"] .nodedc-inspector__section-description,
.nodedc-inspector__section-trigger[data-active="true"] .nodedc-inspector__section-description,
.nodedc-inspector__section-trigger[data-tone="accent"] .nodedc-inspector__section-description {
color: var(--nodedc-text-muted);
}
.nodedc-glass-material::before {
display: none;
}
.nodedc-header__avatar {
background: rgb(226 226 226);
}
.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: 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: rgb(255 255 255 / 0.045);
}
.modal-contract-note[data-tone="warning"] {
background: rgb(255 255 255 / 0.045);
}
.modal-contract-note[data-tone="success"] {
background: rgb(255 255 255 / 0.045);
}
.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,416 @@
.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: rgb(255 255 255 / 0.045);
color: var(--nodedc-text-primary);
padding: 0.85rem 1rem;
}
.error-banner__dot {
width: 0.46rem;
height: 0.46rem;
align-self: start;
margin-top: 0.28rem;
border-radius: 50%;
background: rgb(var(--nodedc-danger-rgb));
}
.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: rgb(255 255 255 / 0.065);
box-shadow: none;
}
.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;
color: var(--nodedc-text-muted);
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);
}
.device-row[data-compatible="true"] .device-row__signal {
background: rgb(var(--nodedc-success-rgb));
}
.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: rgb(255 255 255 / 0.018);
padding: 0.7rem;
}
.latency-trace span {
min-width: 0.16rem;
flex: 1 1 0;
border-radius: 999px 999px 0.12rem 0.12rem;
background: var(--nodedc-text-primary);
}
.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,270 @@
@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__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 {
grid-template-columns: auto minmax(0, 1fr);
}
.error-banner__actions {
grid-column: 2;
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 {
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,83 @@
.scene-tool-window {
--scene-tool-window-top: 5.75rem;
--scene-tool-window-right: 27rem;
position: fixed;
top: var(--scene-tool-window-top);
right: var(--scene-tool-window-right);
max-height: calc(100vh - var(--scene-tool-window-top) - 1.5rem);
}
.scene-tool-window--display {
--scene-tool-window-top: 7.75rem;
--scene-tool-window-right: 28.5rem;
}
.scene-tool-window--layers {
--scene-tool-window-top: 9.75rem;
--scene-tool-window-right: 30rem;
}
.scene-tool-window--layout {
--scene-tool-window-top: 11.75rem;
--scene-tool-window-right: 31.5rem;
}
.nodedc-overlay:has(> .scene-tool-window[data-scene-active="true"]) {
z-index: calc(var(--nodedc-layer-overlay) + 1);
}
.nodedc-overlay[data-placement="center"] {
z-index: calc(var(--nodedc-layer-overlay) + 2);
}
.scene-window-state {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.5rem;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.scene-window-code {
display: block;
overflow: hidden;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-field-bg);
color: var(--nodedc-text-secondary);
padding: 0.72rem 0.8rem;
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-window-note {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.5;
}
@media (max-width: 44rem) {
.scene-tool-window,
.scene-tool-window--display,
.scene-tool-window--layers,
.scene-tool-window--layout {
--scene-tool-window-top: 4.75rem;
--scene-tool-window-right: 0.875rem;
max-height: calc(100vh - 5.625rem);
}
}
@media (min-width: 44.01rem) and (max-width: 70rem) {
.scene-tool-window,
.scene-tool-window--display,
.scene-tool-window--layers,
.scene-tool-window--layout {
--scene-tool-window-top: 13.5rem;
--scene-tool-window-right: 1.5rem;
max-height: calc(100vh - 15rem);
}
}

View File

@ -0,0 +1,116 @@
.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));
}
.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 {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--station-stage);
}
.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;
}

View File

@ -0,0 +1,352 @@
.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:is([data-status="loading"], [data-status="error"])
.rerun-viewport__canvas > div {
visibility: hidden;
}
.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(10 11 14 / 0.9);
}
.busy-indicator {
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
border: 2px solid rgb(255 255 255 / 0.12);
border-top-color: var(--nodedc-text-primary);
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: #06070a;
}
.empty-spatial-stage__grid {
display: none;
}
.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: var(--nodedc-text-muted); }
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: var(--nodedc-text-muted); }
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
.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: var(--nodedc-text-secondary); }
.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: var(--nodedc-text-primary);
}
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
.scene-timeline__follow {
display: inline-flex;
align-items: center;
gap: 0.38rem;
border-radius: var(--nodedc-radius-circle);
background: transparent;
color: var(--nodedc-text-muted);
padding: 0.3rem 0.45rem;
font-size: 0.52rem;
font-weight: 820;
}
.scene-timeline__follow::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.scene-timeline__follow[data-active="true"] {
background: transparent;
color: var(--nodedc-text-primary);
}
.scene-timeline__follow[data-active="true"]::before {
background: 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: var(--nodedc-text-primary); }
.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,624 @@
.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));
}
.feature-row[data-status="ready"] .feature-row__mark {
background: var(--nodedc-text-primary);
}
.feature-row[data-status="contract"] .feature-row__mark {
background: rgb(var(--nodedc-warning-rgb));
}
.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 {
position: relative;
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::before {
position: absolute;
top: 0.7rem;
right: 0.7rem;
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.pipeline-strip > div[data-state="ready"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.pipeline-strip > div[data-state="contract"]::before {
background: rgb(var(--nodedc-warning-rgb));
}
.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;
background: rgb(255 255 255 / 0.012);
}
.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: #0a0b0d;
box-shadow: inset 0 0 0 1px var(--station-hairline);
}
.map-canvas__grid,
.map-canvas__contours {
position: absolute;
inset: 0;
}
.map-canvas__grid {
background: rgb(255 255 255 / 0.012);
}
.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: var(--nodedc-text-secondary);
stroke-linecap: round;
stroke-width: 3;
}
.map-canvas svg circle {
fill: #0a0b0d;
stroke: var(--nodedc-text-secondary);
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: var(--nodedc-text-primary); }
.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: var(--nodedc-text-primary);
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

@ -9,21 +9,25 @@ import {
type EventSocketStatus, type EventSocketStatus,
type LiveRequest, type LiveRequest,
type ReplayRequest, type ReplayRequest,
type ViewerSettings,
} from "./api"; } from "./api";
import { localizeRuntimeMessage } from "./messages";
export type BackendStatus = "checking" | "online" | "degraded" | "offline"; export type BackendStatus = "checking" | "online" | "degraded" | "offline";
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop"; export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
function messageFor(error: unknown): string { function messageFor(error: unknown): string {
if (error instanceof ApiError) { if (error instanceof ApiError) {
const message = localizeRuntimeMessage(error.message) ?? error.message;
return error.status return error.status
? `${error.message} (HTTP ${error.status})` ? `${message} (HTTP ${error.status})`
: error.message; : message;
} }
return "Запрос к локальному API K1 завершился ошибкой."; return "Запрос к локальному сервису устройства завершился ошибкой.";
} }
function measuredLatency(state: ConsoleState | null): number | null { function measuredLatency(state: ConsoleState | null): number | null {
if (state?.source_mode !== "live") return null;
const metrics = state?.metrics; const metrics = state?.metrics;
if (!metrics) return null; if (!metrics) return null;
@ -32,11 +36,10 @@ function measuredLatency(state: ConsoleState | null): number | null {
const segments = [ const segments = [
metrics.mqtt_to_decode_ms, metrics.mqtt_to_decode_ms,
metrics.decode_ms,
metrics.publish_ms, metrics.publish_ms,
].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); ].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return segments.length ? segments.reduce((total, value) => total + value, 0) : null; return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
} }
export function useK1Console() { export function useK1Console() {
@ -128,6 +131,11 @@ export function useK1Console() {
[run], [run],
); );
const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
[run],
);
useEffect(() => { useEffect(() => {
mounted.current = true; mounted.current = true;
void refresh(true); void refresh(true);
@ -169,7 +177,10 @@ export function useK1Console() {
useEffect(() => { useEffect(() => {
const latency = measuredLatency(state); const latency = measuredLatency(state);
if (latency === null) return; if (latency === null) {
setLatencyHistory((values) => (values.length ? [] : values));
return;
}
setLatencyHistory((values) => [...values.slice(-23), latency]); setLatencyHistory((values) => [...values.slice(-23), latency]);
}, [state]); }, [state]);
@ -187,5 +198,6 @@ export function useK1Console() {
startLive, startLive,
startReplay, startReplay,
stop, stop,
updateViewerSettings,
}; };
} }

View File

@ -0,0 +1,550 @@
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 ? "primary" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Выбрано" : "Выбрать"}
</Button>
</div>
</div>
);
}
function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
{values.length ? (
values.map((value, index) => (
<span
key={`${index}-${value}`}
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
title={`${value.toFixed(1)} мс`}
/>
))
) : (
<p>Измерений пока нет. График появится после получения реальных данных.</p>
)}
</div>
);
}
export function DeviceWorkspace({
console,
onOpenSpatialScene,
}: {
console: ConsoleController;
onOpenSpatialScene: () => void;
}) {
const {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh,
clearError,
scan,
connect,
startLive,
startReplay,
stop,
} = console;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
useEffect(() => {
if (state?.selected_device_id) {
setSelectedDeviceId(state.selected_device_id);
return;
}
if (
selectedDeviceId &&
state?.devices &&
!state.devices.some((device) => device.device_id === selectedDeviceId)
) {
setSelectedDeviceId("");
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
const sourceLabel = sourceModeLabel(state?.source_mode);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const submitConnect = async () => {
if (!canConnect) return;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
});
if (succeeded) setPassword("");
};
const submitLive = async () => {
const host = liveHost.trim();
const started = await startLive(host ? { host } : {});
if (started) onOpenSpatialScene();
};
const submitReplay = async () => {
const speed = Number(replaySpeed);
const started = await startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
if (started) onOpenSpatialScene();
};
return (
<div className="device-workspace">
{error ? (
<aside className="error-banner" role="alert">
<span className="error-banner__dot" aria-hidden="true" />
<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 callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение адаптера"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Реальное число декодированных точек"
/>
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
/>
</section>
<div className="device-workspace__grid">
<GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 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="primary"
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="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
onClick={() => void 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="primary"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
</Button>
</div>
)}
<div className="session-footer">
<p>Статус изменится только после ответа локального сервиса.</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
</Button>
</div>
</GlassSurface>
<div className="diagnostics-grid">
<GlassSurface className="status-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
<h2>Локальный контур</h2>
</div>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</header>
<dl className="detail-list">
<DetailRow label="Канал событий">
<span className="inline-state" data-state={eventStatus}>
{eventStatusLabel(eventStatus)}
</span>
</DetailRow>
<DetailRow label="Источник">{sourceLabel}</DetailRow>
<DetailRow label="Адрес устройства">
<code>{state?.k1_ip || "Не получен"}</code>
</DetailRow>
</dl>
</GlassSurface>
<GlassSurface className="latency-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
<h2>Время до публикации</h2>
</div>
<strong className="latency-now">
{formatNumber(latency)} <span>мс</span>
</strong>
</header>
<LatencyTrace values={latencyHistory} />
<div className="latency-legend">
<span>Старые</span>
<span>Последние</span>
</div>
</GlassSurface>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,574 @@
import { useCallback, useMemo, useState } from "react";
import {
Button,
GlassSurface,
Icon,
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 { SceneSettings } from "../sceneSettings";
import type { BackendStatus } from "../useK1Console";
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;
navigation: WorkspaceNavigation;
}
function OverviewWorkspace({
definition,
state,
backendStatus,
navigation,
}: WorkspaceRendererProps) {
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const adapterOnline = backendStatus === "online";
const rerunReady = Boolean(state?.rerun_grpc_url);
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="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение потока"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Без синтетического заполнения"
/>
<MetricCard
eyebrow="РЕЖИМ"
value={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={rerunReady ? "ready" : "idle"}>
<span>03</span>
<strong>Rerun</strong>
<small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</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="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,
navigation,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const 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">
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
</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="ready" />Преобразования</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="primary" 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 { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import wasm from "vite-plugin-wasm";
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ""); const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000"; const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
return { return {
plugins: [react()], plugins: [react(), wasm()],
build: {
target: "esnext",
},
server: { server: {
host: "127.0.0.1", host: "127.0.0.1",
port: 5173, port: 5173,

View File

@ -1,9 +1,11 @@
# K1 live console and Foxglove bridge # K1 live console and embedded Rerun bridge
Status: alpha implementation for the verified firmware-3 MQTT streams. It uses Status: the Rerun alpha milestone is implemented for the verified firmware-3
real K1 data and does not generate a placeholder cloud, pose or latency value. MQTT streams. Live capture and replay use real K1 point-cloud and pose messages;
the application does not generate a placeholder cloud, trajectory, camera frame
or latency value.
## Architecture ## Active device-to-scene path
```text ```text
K1 lio_pcl / lio_pose K1 lio_pcl / lio_pose
@ -11,139 +13,263 @@ K1 lio_pcl / lio_pose
v v
read-only MQTT subscription on TCP 1883 read-only MQTT subscription on TCP 1883
| |
+--> raw .k1mqtt + JSONL + SHA-256 summary (first) +--> raw .k1mqtt + JSONL + SHA-256 summary (written first)
| |
v v
bounded latest-wins preview queue (32 messages) bounded latest-wins preview queue (32 messages)
| |
v v
raw-LZ4/protobuf decoder --> foxglove.PointCloud / Pose / SceneUpdate reviewed raw-LZ4/protobuf decoders
| |
v v
ws://127.0.0.1:8765 --> Foxglove 3D Rerun Points3D + Transform3D + LineStrips3D
|
v
Rerun gRPC/proxy on TCP 9876
|
+--> rerun_grpc_url in REST/WebSocket state
|
v
self-hosted @rerun-io/web-viewer inside NODE.DC Control Station
React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000 React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
``` ```
The Paho MQTT callback never decodes the point cloud. It writes and flushes the The Paho MQTT callback does not decode or render the point cloud. It first
raw frame and metadata, then enqueues a preview reference. If visualization cannot writes and flushes the raw frame and metadata, then enqueues a preview message.
keep up, the oldest queued preview is discarded while the raw capture continues. If visualization cannot keep up, the oldest queued preview is discarded while
the raw capture continues. Rerun work stays on the dedicated publisher thread
and cannot block raw-first evidence capture.
The current live/replay runtime instantiates only `RerunBridge`. The former
Foxglove implementation is not a parallel runtime and does not listen on TCP
8765; it is retained only as a legacy regression module and test oracle.
## Start the local application ## Start the local application
Prerequisites are the repository-local Python environment and Node.js 20 or Prerequisites are the repository-local Python environment and Node.js 20.19+ or
newer. No global Python package or system component is installed. 22.12+. No global Python package or system component is installed.
```bash ```bash
uv sync --group dev uv sync --group dev
cd apps/k1-viewer cd apps/k1-viewer
npm install npm install
npm run typecheck
npm run build npm run build
cd ../.. cd ../..
uv run k1link serve uv run k1link serve
``` ```
Open `http://127.0.0.1:8000`. `k1link serve` intentionally exposes no LAN bind Open `http://127.0.0.1:8000`. The static Control Station, REST API, WebSocket
option because its provisioning endpoint temporarily receives a Wi-Fi password. state channel and credential endpoint bind to loopback. The Rerun gRPC server is
The password is accepted only in the POST body, is never logged or persisted by a separate listener with a different network boundary described under
the connector, and is cleared from the React form after success. [Network and security boundary](#network-and-security-boundary).
## Replay the captured proof ## Replay a reviewed capture
1. In **Session**, select **Replay capture**. 1. In **Парк → Локальное устройство**, choose the replay mode.
2. Enter the repository-relative path: 2. Enter a native `mqtt.raw.k1mqtt` capture or a reviewed four-column TSV,
for example:
```text ```text
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
``` ```
3. Use speed `1` and enable loop for initial viewer setup. 3. Select replay speed and optional looping, then start the session.
4. Start replay, then select **Open Foxglove 3D**. 4. The runtime starts the process-wide Rerun `RecordingStream` and its
5. In a Foxglove 3D panel, enable `/k1/points`, `/k1/pose` and gRPC/proxy server on TCP 9876 before the first replay. Later sessions reuse it.
`/k1/trajectory`. 5. Open **Наблюдение → Пространственная сцена**. With the manual source field
6. For the cloud, choose `intensity` as the color field and a Turbo/Rainbow empty, the embedded viewer automatically uses:
colormap or custom two-color gradient. Foxglove can also color by `x`, `y`,
`z` and its derived `<distance>`.
The reviewed TSV reader accepts exactly four columns: receive timestamp, topic, ```text
declared payload length and hex payload. It verifies timestamp, UTF-8 topic, rerun+http://127.0.0.1:9876/proxy
declared length, hex encoding and allocation bounds. Native ```
`mqtt.raw.k1mqtt` captures are supported directly and use their sibling metadata
timestamps when present. 6. Use **Отображение** and **Слои** to adjust the real cloud and trajectory.
7. Stop the session when finished. Acquisition and session metrics stop, while
the embedded viewer and `rerun_grpc_url` remain ready. A later session resets
scene-local state and reuses the same listener without reloading the page.
The TSV reader accepts exactly receive timestamp, topic, declared payload length
and hexadecimal payload. It verifies timestamp, UTF-8 topic, declared length,
hex encoding and allocation bounds. Native `.k1mqtt` captures are supported
directly and use their sibling metadata receive timestamps when present.
## Connect and stream live ## Connect and stream live
1. Power K1 to its normal steady-green standby state. 1. Power K1 to its normal steady-green standby state.
2. Confirm the manual power checklist in **Connect**. 2. Confirm the manual power checklist in **Парк → Локальное устройство**.
3. Run the real six-second BLE scan and select the K1 candidate. 3. Run the real six-second BLE scan and select the intended device from the
4. Enter the existing router SSID/password and press **Provision Wi-Fi & complete visible-device list.
connect**. That button is the explicit authorization for one reviewed 99-byte 4. Enter the existing router SSID/password and explicitly authorize the reviewed
provisioning write; the backend never retries automatically. provisioning write. The backend does not retry the write automatically.
5. When K1 reports a non-AP private address, press **Start live stream**. 5. When K1 reports a non-AP private address, start live reception.
6. Open Foxglove before scanning if convenient. 6. Wait until the UI reports that the local Rerun bridge is ready. No manual
7. Double-click the physical K1 button to start scanning. Double-click again to viewer URL is needed.
stop, wait for the LED to return to steady green, then stop the local session. 7. Open **Наблюдение → Пространственная сцена**.
8. Double-click the physical K1 button to start scanning. Real point frames and
pose/trajectory updates then appear in the embedded viewport.
9. Double-click K1 again to stop physical scanning, wait for steady green, then
stop the local session so captures and summaries are finalized.
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a
redacted manifest, operator notes, raw MQTT frames, per-message metadata and a redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
hash summary. No application request topic is published. hash summary. The connector subscribes to the fixed report-topic allowlist and
does not publish an application request or modeling command.
## Published topics ## Automatic Rerun source and lifecycle
| Topic | Foxglove schema | Meaning | On the first live or replay session, `RerunBridge`:
- creates an explicit `RecordingStream("nodedc_device_spatial")`;
- installs the default spatial blueprint;
- starts the gRPC/proxy server on TCP 9876 with a 512 MiB late-client buffer;
- reports `rerun+http://127.0.0.1:9876/proxy` only after the server is ready;
- accepts the local development and production browser origins used by this
repository;
- keeps the recording and URL alive until `k1link serve` shuts down.
At every session start it resets the trajectory, current point count, metrics,
blueprint and session-local visible time, then feeds the new source through the
same recording. This process-wide lifecycle is intentional: the Rerun 0.34.1
browser receiver can remain connected after canvas teardown, so restarting the
native listener on the same port is not a reliable session boundary.
The frontend prefers an operator-entered source when one is present; otherwise
it automatically uses `rerun_grpc_url` from backend state. A manual field can
still open another compatible `rerun+http://.../proxy` source or an RRD file
served over HTTP(S). No externally hosted viewer UI is involved:
`@rerun-io/web-viewer` and its WebAssembly runtime are bundled with the local
application. FastAPI carries control state only; the browser reads the point
stream directly from the Rerun gRPC/proxy endpoint.
## Rerun entities
| Entity | Rerun archetype | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `/k1/points` | `foxglove.PointCloud` | metric XYZ float32 + uint8 intensity, stride 16 | | `/world` | `ViewCoordinates` | right-handed Z-up display convention |
| `/k1/pose` | `foxglove.PoseInFrame` | current decoded K1 pose in the `map` frame | | `/world/points` | `Points3D` | decoded metric XYZ with computed or available RGB colors |
| `/k1/trajectory` | `foxglove.SceneUpdate` | bounded cyan line strip, throttled while growing | | `/world/sensor_pose` | `Transform3D` + `TransformAxes3D` | current decoded translation, xyzw quaternion and pose axes |
| `/k1/metrics` | JSON schema | latency, decode/publish time, FPS, points and drops | | `/world/trajectory` | `LineStrips3D` | bounded path of up to 20,000 decoded poses |
Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as verified. Point count, frame rate, queue drops and measured pipeline time remain product
No axis swap, quaternion normalization or vehicle extrinsic is silently applied. metrics in the outer Control Station; they are not logged as Rerun entities and
Only the low byte of `rgbi` is labeled intensity; interpreting the upper bytes therefore cannot create additional automatic viewer panes.
as RGB remains unverified.
## Latency semantics Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as decoded.
No axis swap, quaternion normalization, scanner-to-vehicle extrinsic or SLAM
post-processing is silently applied. The point entity is in `/world` rather
than under the pose transform, so an already map-frame cloud is not transformed
twice. The physical K1 axes and a scanner-to-vehicle transform still require
calibration before vehicle mounting.
`pipeline_ms` / `mqtt_to_publish_ms` is measured with the host monotonic clock Only the low byte of firmware-3 `rgbi` has been verified as intensity. The
from MQTT callback receipt through raw disk write, bounded queue wait, decode, upper bytes are not labeled RGB. Legacy frames use RGB only when those decoded
packing and Foxglove channel publish. Rolling p50/p95 values use the last 512 fields are actually present.
published decoded messages.
This is the exact Mac pipeline latency, not yet sensor-photon-to-screen latency. ## Scene controls
The K1 header timestamp epoch has not been proven, Foxglove rendering time is
outside the Python publisher, and display latency is not observable without a The current product controls call `POST /api/viewer/settings`; backend state is
clock-correlated device timestamp or high-speed-camera experiment. Foxglove authoritative and the bridge applies the updated settings to subsequent
message time therefore uses host receive Unix time while durations use messages.
`monotonic_ns`.
| Control | Implemented behavior |
| --- | --- |
| Point size | Rerun UI-point radius from 0.5 to 12.0; default 2.5 |
| Color attribute | intensity, height Z, distance from the point-coordinate origin, available RGB, or solid class/custom color |
| Palette | Turbo, Viridis, Plasma, grayscale or operator-selected custom color |
| Accumulation | sliding session-local `stream_time` window from 0 to 120 seconds; default 12 seconds |
| Point layer | shows or clears `/world/points` |
| Trajectory | shows or clears the decoded path; publication is throttled while it grows |
| Grid | updates visibility of the Rerun 3D line grid through the active blueprint |
The default 12-second accumulation is a viewer time range over real point
frames; it does not duplicate or alter the raw capture. An accumulation of zero
shows only the current frame. RGB mode falls back to the verified scalar
coloring path when the active point format has no RGB fields. The current
`class`/custom behavior is a selected solid color, not semantic segmentation.
Projection switching, a custom playback timeline, semantic object/mask layers,
camera frustums and persisted RBL/layout profiles are not wired yet and must not
be inferred from the implemented controls above.
## Time and latency semantics
The Rerun `capture_time` timeline uses
`StreamMessage.received_at_epoch_ns`: Unix time when the Mac received the MQTT
message. The K1 header timestamp epoch is not proven, so `capture_time` is not
a sensor acquisition timestamp. The viewer accumulation window uses
`stream_time`, assigned when the current live/replay run publishes a message;
therefore replayed historical timestamps do not mix two sequential sessions.
For live MQTT only, `pipeline_ms` / `mqtt_to_publish_ms` uses the Mac monotonic
clock from MQTT callback receipt through raw disk write, bounded queue wait,
decode, Rerun packing and the SDK log call. Replay and idle states expose no
latency value. Browser fetch, WebAssembly ingestion, GPU rendering and display
scanout are outside this measurement.
The displayed value is therefore Mac pipeline latency, not sensor-photon-to-
screen latency. End-to-end display latency requires a proven device clock or a
separate clock-correlated/high-speed-camera experiment.
## Network and security boundary
There are two different listeners:
- FastAPI, the WebSocket control plane and Wi-Fi credential endpoint bind only
to `127.0.0.1:8000`;
- Rerun gRPC/proxy on TCP 9876 currently binds **all network interfaces**, even
though the URL returned to the local browser contains `127.0.0.1`.
The Rerun endpoint has no connector-level authentication and no TLS. It may
expose point clouds, trajectory and mapped interiors to any host that can reach
the port. Use it only on a trusted laboratory LAN, block TCP 9876 at the router
and host firewall as appropriate, and never forward it directly to the public
Internet or cellular WAN. Remote operation requires an authenticated TLS reverse
proxy or another reviewed secure transport before deployment.
The 512 MiB Rerun server buffer limits retained late-client data but is not an
access-control mechanism. The listener and embedded viewer intentionally remain
ready between acquisition sessions. Stop `k1link serve` when the network
listener and its process memory must be closed unconditionally.
## Current boundaries ## Current boundaries
- Raw panoramic camera frames were not present on the observed MQTT report - Raw panoramic camera frames were absent from the observed MQTT report topics.
topics; this milestone intentionally ships point cloud plus trajectory only. This milestone contains point cloud and pose/trajectory; operational metrics
- Physical double-click remains the start/stop control. A future MQTT modeling are rendered by the outer Control Station.
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
publisher needs a separately reviewed state-changing profile. publisher needs a separately reviewed state-changing profile.
- The React application opens the official Foxglove viewer over a local - No terrain map, elevation model, obstacle segmentation, localization fusion,
WebSocket. It does not embed or fork the proprietary modern viewer; Foxglove mission planner or vehicle control is implemented by this viewer milestone.
account/seat terms apply to that viewer independently of this connector. - Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
- Exact path axes and a scanner-to-vehicle transform must be calibrated before calibration task.
mounting on the unmanned platform. - A single local runtime owns TCP 9876. A port collision causes a visible bridge
startup error rather than silently selecting another source. The Rerun native
listener lives for the lifetime of `k1link serve` and is reused by later
sessions in that process; shutdown closes it explicitly.
## Verification checkpoint — 2026-07-15 ## Verification checkpoint — 2026-07-16
The full captured 180-second scan was passed through the production Foxglove A powered K1 capture was passed through the active MQTT → decoder → Rerun gRPC →
bridge without a viewer-side substitute: embedded Web Viewer path without a viewer-side substitute:
| Check | Result | | Check | Result |
| --- | ---: | | --- | ---: |
| MQTT messages read | 2,836 | | real MQTT messages processed | 80 |
| `lio_pcl` frames published | 1,140 | | `lio_pcl` frames published | 38 |
| `lio_pose` frames published | 1,215 | | `lio_pose` frames published | 42 |
| points packed and published | 4,165,862 | | points in the last cloud | 2,775 |
| decoder errors | 0 | | decoder errors | 0 |
| final trajectory poses | 1,215 |
The same replay was then started through the FastAPI/React contract at `10x`. The 38 point frames and 42 pose frames account for all 80 observed messages.
The API exposed the loopback Foxglove URL, live point/pose metrics and non-zero This proves current real-device decoding, bridge publication and embedded-viewer
preview-drop accounting under deliberate acceleration, and released TCP 8765 delivery for point cloud plus trajectory. It does not prove a camera channel or
after the stop request. This validates replay, overload behavior and lifecycle; sensor-to-screen latency.
the next checkpoint is the powered K1 ideal-LAN live run.
## Legacy Foxglove regression module
`src/k1link/viewer/foxglove_bridge.py` and its tests remain in the repository
to compare decoder/packing behavior and preserve earlier evidence. They are not
instantiated by `VisualizationRuntime`, are not the Control Station source, and
do not make TCP 8765 part of the current operator path.

View File

@ -16,6 +16,7 @@ dependencies = [
"foxglove-sdk==0.25.3", "foxglove-sdk==0.25.3",
"lz4>=4.4,<5", "lz4>=4.4,<5",
"paho-mqtt>=2.1,<3", "paho-mqtt>=2.1,<3",
"rerun-sdk==0.34.1",
"rich>=13.9,<15", "rich>=13.9,<15",
"typer>=0.15,<1", "typer>=0.15,<1",
"uvicorn[standard]>=0.35,<1", "uvicorn[standard]>=0.35,<1",

View File

@ -0,0 +1,386 @@
from __future__ import annotations
import math
import time
from collections import deque
from collections.abc import Callable
from dataclasses import asdict, dataclass
from typing import Literal
import numpy as np
import rerun as rr
from rerun import blueprint as rrb
from k1link.protocol.streams import (
LegacyPointCloudFrame,
LegacyPoseFrame,
LioPointCloudFrame,
LioPoseFrame,
StreamDecodeError,
decode_legacy_pointcloud,
decode_legacy_pose,
decode_lio_pcl,
decode_lio_pose,
)
from k1link.viewer.foxglove_bridge import BridgeMetrics
from k1link.viewer.messages import StreamMessage
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
MAX_TRAJECTORY_POSES = 20_000
DEFAULT_GRPC_PORT = 9876
DEFAULT_CORS_ORIGINS = (
"http://127.0.0.1:5173",
"http://localhost:5173",
"http://127.0.0.1:4173",
"http://localhost:4173",
"http://127.0.0.1:8000",
"http://localhost:8000",
)
@dataclass(frozen=True, slots=True)
class RerunSceneSettings:
point_size: float = 2.5
color_mode: PointColorMode = "intensity"
palette: PointPalette = "turbo"
custom_color: str = "#f7f8f4"
accumulation_seconds: float = 12.0
show_points: bool = True
show_trajectory: bool = True
show_grid: bool = True
def as_dict(self) -> dict[str, object]:
return asdict(self)
SettingsProvider = Callable[[], RerunSceneSettings]
class RerunBridge:
"""Decode verified device topics into a self-hosted Rerun recording stream."""
def __init__(
self,
*,
grpc_port: int = DEFAULT_GRPC_PORT,
metrics: BridgeMetrics | None = None,
settings_provider: SettingsProvider | None = None,
cors_allow_origin: tuple[str, ...] = DEFAULT_CORS_ORIGINS,
recording_factory: Callable[[str], rr.RecordingStream] | None = None,
) -> None:
self.metrics = metrics or BridgeMetrics()
self._settings_provider = settings_provider or RerunSceneSettings
self._settings = self._settings_provider()
self._recording = (recording_factory or rr.RecordingStream)(
"nodedc_device_spatial"
)
blueprint = _blueprint(self._settings)
self._url = self._recording.serve_grpc(
grpc_port=grpc_port,
default_blueprint=blueprint,
server_memory_limit="512MiB",
newest_first=True,
cors_allow_origin=list(cors_allow_origin),
)
self._recording.send_blueprint(
blueprint,
make_active=True,
make_default=True,
)
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
self._recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
static=True,
)
self._path: deque[tuple[float, float, float]] = deque(
maxlen=MAX_TRAJECTORY_POSES
)
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._closed = False
@property
def grpc_url(self) -> str:
return self._url
def begin_session(self, metrics: BridgeMetrics | None = None) -> None:
"""Reset session-local state while keeping the process-wide server alive."""
if self._closed:
raise RuntimeError("Rerun bridge is already closed")
if metrics is not None:
self.metrics = metrics
self._settings = self._settings_provider()
self._path.clear()
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._recording.set_time("stream_time", timestamp=time.time())
self._recording.log("/world", rr.Clear(recursive=True))
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
self._recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
static=True,
)
self._recording.send_blueprint(
_blueprint(self._settings),
make_active=True,
make_default=True,
)
def process(self, message: StreamMessage) -> None:
started_ns = time.monotonic_ns()
self.metrics.received(len(message.payload))
self._apply_latest_settings()
self._set_message_time(message)
try:
if message.topic.endswith("/lio_pcl"):
self._publish_lio_pcl(decode_lio_pcl(message.payload))
point_frame = True
elif message.topic == "RealtimePointcloud":
self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload))
point_frame = True
elif message.topic.endswith("/lio_pose"):
self._publish_lio_pose(decode_lio_pose(message.payload))
point_frame = False
elif message.topic == "RealtimePath":
self._publish_legacy_pose(decode_legacy_pose(message.payload))
point_frame = False
else:
return
except StreamDecodeError:
self.metrics.decode_error()
return
published_ns = time.monotonic_ns()
decode_publish_ms = (published_ns - started_ns) / 1_000_000
if point_frame:
self.metrics.published_pcl(
self._last_point_count,
published_ns,
decode_publish_ms,
)
else:
self.metrics.published_pose(
published_ns,
decode_publish_ms,
len(self._path),
)
if message.source == "live_mqtt" and message.received_monotonic_ns is not None:
self.metrics.record_latency(
(published_ns - message.received_monotonic_ns) / 1_000_000
)
def close(self) -> None:
if self._closed:
return
self._closed = True
recording = self._recording
try:
recording.flush(timeout_sec=5.0)
finally:
recording.disconnect()
# The Python wrapper owns the native gRPC server. Release it immediately
# instead of waiting for the publisher thread frame to be collected.
del self._recording
def _set_message_time(self, message: StreamMessage) -> None:
self._recording.set_time("stream_time", timestamp=time.time())
self._recording.set_time(
"capture_time",
timestamp=message.received_at_epoch_ns / 1_000_000_000,
)
self._recording.set_time("message_sequence", sequence=message.sequence)
def _apply_latest_settings(self) -> None:
settings = self._settings_provider()
if settings == self._settings:
return
self._settings = settings
self._recording.send_blueprint(_blueprint(settings))
def _publish_lio_pcl(self, frame: LioPointCloudFrame) -> None:
count = len(frame.points)
positions = np.empty((count, 3), dtype=np.float32)
intensities = np.empty(count, dtype=np.uint8)
scaler = frame.header.scaler
for index, point in enumerate(frame.points):
positions[index] = point.scaled_xyz(scaler)
intensities[index] = point.intensity
self._publish_points(positions, intensities, rgb=None)
def _publish_legacy_pcl(self, frame: LegacyPointCloudFrame) -> None:
count = len(frame.points)
positions = np.empty((count, 3), dtype=np.float32)
intensities = np.empty(count, dtype=np.uint8)
rgb = np.empty((count, 3), dtype=np.uint8)
for index, point in enumerate(frame.points):
positions[index] = (point.x, point.y, point.z)
intensities[index] = point.intensity
rgb[index] = (point.r, point.g, point.b)
self._publish_points(positions, intensities, rgb=rgb)
def _publish_points(
self,
positions: np.ndarray,
intensities: np.ndarray,
*,
rgb: np.ndarray | None,
) -> None:
self._last_point_count = int(positions.shape[0])
if not self._settings.show_points:
self._recording.log("/world/points", rr.Clear(recursive=False))
return
colors = _point_colors(positions, intensities, rgb, self._settings)
self._recording.log(
"/world/points",
rr.Points3D(
positions,
colors=colors,
radii=rr.Radius.ui_points(self._settings.point_size),
),
)
def _publish_lio_pose(self, frame: LioPoseFrame) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw)
def _publish_legacy_pose(self, frame: LegacyPoseFrame) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw)
def _publish_pose(
self,
position_xyz: tuple[float, float, float],
orientation_xyzw: tuple[float, float, float, float],
) -> None:
self._recording.log(
"/world/sensor_pose",
rr.Transform3D(
translation=position_xyz,
quaternion=rr.Quaternion(xyzw=orientation_xyzw),
),
)
self._path.append(position_xyz)
if not self._settings.show_trajectory:
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
return
now_ns = time.monotonic_ns()
if (
len(self._path) > 2
and len(self._path) % 20 != 0
and now_ns - self._last_trajectory_publish_ns < 200_000_000
):
return
self._last_trajectory_publish_ns = now_ns
self._recording.log(
"/world/trajectory",
rr.LineStrips3D(
[list(self._path)],
colors=[247, 248, 244, 255],
radii=rr.Radius.ui_points(2.0),
),
)
def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
time_range = rr.VisibleTimeRange(
"stream_time",
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
end=rr.TimeRangeBoundary.cursor_relative(),
)
return rrb.Blueprint(
rrb.Spatial3DView(
origin="/world",
name="Пространственная сцена",
background=[7, 8, 10, 255],
line_grid=rrb.LineGrid3D(
visible=settings.show_grid,
color=[86, 91, 99, 110],
stroke_width=0.75,
),
time_ranges=[time_range],
),
auto_layout=False,
auto_views=False,
collapse_panels=True,
)
def _point_colors(
positions: np.ndarray,
intensities: np.ndarray,
rgb: np.ndarray | None,
settings: RerunSceneSettings,
) -> np.ndarray:
if settings.color_mode == "rgb" and rgb is not None:
return rgb
if settings.color_mode == "class":
color = _parse_hex_color(settings.custom_color)
return np.tile(np.asarray(color, dtype=np.uint8), (positions.shape[0], 1))
if settings.color_mode == "height":
values = _normalize(positions[:, 2])
elif settings.color_mode == "distance":
values = _normalize(np.linalg.norm(positions, axis=1))
else:
values = intensities.astype(np.float32) / 255.0
return _apply_palette(values, settings.palette, settings.custom_color)
def _normalize(values: np.ndarray) -> np.ndarray:
if values.size == 0:
return values.astype(np.float32)
minimum = float(np.min(values))
maximum = float(np.max(values))
if not math.isfinite(minimum) or not math.isfinite(maximum) or maximum <= minimum:
return np.full(values.shape, 0.5, dtype=np.float32)
return ((values - minimum) / (maximum - minimum)).astype(np.float32)
def _apply_palette(values: np.ndarray, palette: PointPalette, custom: str) -> np.ndarray:
clipped = np.clip(values, 0.0, 1.0)
if palette == "custom":
color = _parse_hex_color(custom)
return np.tile(np.asarray(color, dtype=np.uint8), (values.size, 1))
if palette == "grayscale":
channel = np.rint(clipped * 255).astype(np.uint8)
return np.column_stack((channel, channel, channel))
stops = {
"turbo": (
(0.00, (48, 18, 59)),
(0.25, (40, 126, 231)),
(0.50, (42, 240, 154)),
(0.75, (246, 214, 55)),
(1.00, (180, 4, 38)),
),
"viridis": (
(0.00, (68, 1, 84)),
(0.25, (59, 82, 139)),
(0.50, (33, 145, 140)),
(0.75, (94, 201, 98)),
(1.00, (253, 231, 37)),
),
"plasma": (
(0.00, (13, 8, 135)),
(0.25, (126, 3, 168)),
(0.50, (204, 71, 120)),
(0.75, (248, 149, 64)),
(1.00, (240, 249, 33)),
),
}[palette]
positions = np.asarray([item[0] for item in stops], dtype=np.float32)
colors = np.asarray([item[1] for item in stops], dtype=np.float32)
channels = [np.interp(clipped, positions, colors[:, index]) for index in range(3)]
return np.rint(np.column_stack(channels)).astype(np.uint8)
def _parse_hex_color(value: str) -> tuple[int, int, int]:
candidate = value.removeprefix("#")
if len(candidate) != 6:
return 247, 248, 244
try:
return tuple(int(candidate[index : index + 2], 16) for index in (0, 2, 4)) # type: ignore[return-value]
except ValueError:
return 247, 248, 244

View File

@ -11,9 +11,10 @@ from typing import Literal, TypedDict
from k1link.artifacts import utc_now_iso, write_json_atomic from k1link.artifacts import utc_now_iso, write_json_atomic
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
from k1link.viewer.foxglove_bridge import BridgeMetrics, FoxgloveBridge, MetricsSnapshot from k1link.viewer.foxglove_bridge import BridgeMetrics, MetricsSnapshot
from k1link.viewer.messages import StreamMessage from k1link.viewer.messages import StreamMessage
from k1link.viewer.replay import iter_replay_messages from k1link.viewer.replay import iter_replay_messages
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
RuntimePhase = Literal[ RuntimePhase = Literal[
"idle", "idle",
@ -25,6 +26,7 @@ RuntimePhase = Literal[
] ]
SourceMode = Literal["idle", "live", "replay"] SourceMode = Literal["idle", "live", "replay"]
StateCallback = Callable[[], None] StateCallback = Callable[[], None]
BridgeFactory = Callable[..., RerunBridge]
class RuntimeSnapshot(TypedDict): class RuntimeSnapshot(TypedDict):
@ -33,22 +35,36 @@ class RuntimeSnapshot(TypedDict):
source_mode: SourceMode source_mode: SourceMode
foxglove_ws_url: str | None foxglove_ws_url: str | None
foxglove_viewer_url: str | None foxglove_viewer_url: str | None
rerun_grpc_url: str | None
viewer_settings: dict[str, object]
metrics: MetricsSnapshot metrics: MetricsSnapshot
class VisualizationRuntime: class VisualizationRuntime:
"""Own one bounded live/replay source and one deterministic publisher thread.""" """Own one bounded live/replay source and one deterministic publisher thread."""
def __init__(self, *, on_state_change: StateCallback | None = None) -> None: def __init__(
self,
*,
on_state_change: StateCallback | None = None,
grpc_port: int = DEFAULT_GRPC_PORT,
bridge_factory: BridgeFactory | None = None,
) -> None:
self._lock = threading.Lock() self._lock = threading.Lock()
self._on_state_change = on_state_change self._on_state_change = on_state_change
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._phase: RuntimePhase = "idle" self._phase: RuntimePhase = "idle"
self._message = "Готово. Включите K1 и начните с поиска по Bluetooth." self._message = "Готово. Включите устройство и начните с поиска по Bluetooth."
self._source_mode: SourceMode = "idle" self._source_mode: SourceMode = "idle"
self._foxglove_ws_url: str | None = None self._foxglove_ws_url: str | None = None
self._foxglove_viewer_url: str | None = None self._foxglove_viewer_url: str | None = None
self._rerun_grpc_url: str | None = None
self._grpc_port = grpc_port
self._bridge_factory = bridge_factory or RerunBridge
self._bridge: RerunBridge | None = None
self._closed = False
self._scene_settings = RerunSceneSettings()
self._metrics = BridgeMetrics() self._metrics = BridgeMetrics()
def snapshot(self) -> RuntimeSnapshot: def snapshot(self) -> RuntimeSnapshot:
@ -59,13 +75,21 @@ class VisualizationRuntime:
"source_mode": self._source_mode, "source_mode": self._source_mode,
"foxglove_ws_url": self._foxglove_ws_url, "foxglove_ws_url": self._foxglove_ws_url,
"foxglove_viewer_url": self._foxglove_viewer_url, "foxglove_viewer_url": self._foxglove_viewer_url,
"rerun_grpc_url": self._rerun_grpc_url,
"viewer_settings": self._scene_settings.as_dict(),
"metrics": self._metrics.snapshot(), "metrics": self._metrics.snapshot(),
} }
def update_scene_settings(self, settings: RerunSceneSettings) -> RuntimeSnapshot:
with self._lock:
self._scene_settings = settings
self._notify()
return self.snapshot()
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None: def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
resolved = path.expanduser().resolve() resolved = path.expanduser().resolve()
if not resolved.is_file(): if not resolved.is_file():
raise ValueError("файл записи не найден на этом MacBook") raise ValueError("файл записи не найден на этом компьютере")
if not math.isfinite(speed) or speed < 0: if not math.isfinite(speed) or speed < 0:
raise ValueError("скорость повтора должна быть неотрицательным числом") raise ValueError("скорость повтора должна быть неотрицательным числом")
# Validate the reviewed shape before changing runtime state. # Validate the reviewed shape before changing runtime state.
@ -96,7 +120,7 @@ class VisualizationRuntime:
self._start( self._start(
source_mode="live", source_mode="live",
phase="starting_live", phase="starting_live",
message="Запускаем приём MQTT и мост Foxglove.", message="Запускаем приём MQTT и локальный мост визуализации.",
target=lambda: self._run_live( target=lambda: self._run_live(
host, host,
out_dir.expanduser().resolve(), out_dir.expanduser().resolve(),
@ -123,6 +147,34 @@ class VisualizationRuntime:
assert thread is not None assert thread is not None
thread.join(timeout=wait_seconds) thread.join(timeout=wait_seconds)
def close(self, *, wait_seconds: float = 5.0) -> None:
"""Stop the active source and release the process-wide visual bridge."""
with self._lock:
self._closed = True
thread = self._thread
if thread is not None and thread.is_alive():
self._phase = "stopping"
self._message = "Завершаем локальный поток и визуальный мост."
self._stop_event.set()
else:
self._phase = "idle"
self._source_mode = "idle"
self._message = "Локальный поток завершён."
self._notify()
if thread is not None and thread.is_alive():
thread.join(timeout=wait_seconds)
with self._lock:
thread_alive = self._thread is not None and self._thread.is_alive()
bridge = None if thread_alive else self._bridge
if not thread_alive:
self._bridge = None
self._rerun_grpc_url = None
if bridge is not None:
bridge.close()
self._notify()
def _start( def _start(
self, self,
*, *,
@ -132,6 +184,8 @@ class VisualizationRuntime:
target: Callable[[], None], target: Callable[[], None],
) -> None: ) -> None:
with self._lock: with self._lock:
if self._closed:
raise RuntimeError("runtime завершён; перезапустите локальный сервер")
if self._thread is not None and self._thread.is_alive(): if self._thread is not None and self._thread.is_alive():
raise RuntimeError("поток уже запущен; сначала остановите текущую сессию") raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
self._stop_event = threading.Event() self._stop_event = threading.Event()
@ -199,7 +253,8 @@ class VisualizationRuntime:
out_dir / "captures" / "mqtt_live", out_dir / "captures" / "mqtt_live",
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
on_ready=lambda: self._set_running( on_ready=lambda: self._set_running(
"live", "Приём запущен. Теперь дважды нажмите физическую кнопку K1." "live",
"Приём запущен. Теперь дважды нажмите физическую кнопку устройства.",
), ),
on_message_recorded=on_message, on_message_recorded=on_message,
should_stop=self._stop_event.is_set, should_stop=self._stop_event.is_set,
@ -220,6 +275,7 @@ class VisualizationRuntime:
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32) messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
source_done = threading.Event() source_done = threading.Event()
publisher_ready = threading.Event() publisher_ready = threading.Event()
publisher_aborted = threading.Event()
publisher_error: list[BaseException] = [] publisher_error: list[BaseException] = []
def enqueue(message: StreamMessage) -> None: def enqueue(message: StreamMessage) -> None:
@ -240,17 +296,41 @@ class VisualizationRuntime:
self._metrics.preview_dropped() self._metrics.preview_dropped()
def publish() -> None: def publish() -> None:
bridge: FoxgloveBridge | None = None bridge: RerunBridge | None = None
try: try:
bridge = FoxgloveBridge(metrics=self._metrics) bridge = self._bridge
if bridge is None:
candidate = self._bridge_factory(
grpc_port=self._grpc_port,
metrics=self._metrics,
settings_provider=self._current_scene_settings,
)
with self._lock: with self._lock:
self._foxglove_ws_url = bridge.websocket_url if self._closed:
self._foxglove_viewer_url = bridge.viewer_url publisher_aborted.set()
if self._phase != "stopping": else:
self._bridge = candidate
bridge = candidate
if publisher_aborted.is_set():
candidate.close()
publisher_ready.set()
return
assert bridge is not None
bridge.begin_session(self._metrics)
with self._lock:
if self._closed:
publisher_aborted.set()
else:
self._rerun_grpc_url = bridge.grpc_url
if not self._closed and self._phase != "stopping":
self._phase = running_phase self._phase = running_phase
self._message = "Мост Foxglove готов; источник данных запущен." self._message = (
"Локальный Rerun-мост готов; источник данных запущен."
)
publisher_ready.set() publisher_ready.set()
self._notify() self._notify()
if publisher_aborted.is_set():
return
while not source_done.is_set() or not messages.empty(): while not source_done.is_set() or not messages.empty():
if self._stop_event.is_set() and source_done.is_set() and messages.empty(): if self._stop_event.is_set() and source_done.is_set() and messages.empty():
break break
@ -265,25 +345,51 @@ class VisualizationRuntime:
if self._metrics.snapshot()["messages_received"] % 10 == 0: if self._metrics.snapshot()["messages_received"] % 10 == 0:
self._notify() self._notify()
except BaseException as exc: except BaseException as exc:
with self._lock:
closed = self._closed
if not closed:
publisher_error.append(exc) publisher_error.append(exc)
else:
publisher_aborted.set()
publisher_ready.set() publisher_ready.set()
self._stop_event.set() self._stop_event.set()
finally: finally:
if bridge is not None: if bridge is not None:
with self._lock:
close_bridge = self._closed and self._bridge is bridge
if close_bridge:
self._bridge = None
self._rerun_grpc_url = None
if close_bridge:
bridge.close() bridge.close()
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True) def join_publisher() -> None:
# Never orphan a publisher: the session thread remains its owner.
# On process shutdown both are daemon threads, so an irrecoverably
# blocked native call cannot prevent the operating system from exit.
while publisher.is_alive():
publisher.join(timeout=0.25)
publisher = threading.Thread(target=publish, name="k1-rerun-publisher", daemon=True)
publisher.start() publisher.start()
if not publisher_ready.wait(timeout=15.0): if not publisher_ready.wait(timeout=15.0):
self._finish_error("Мост Foxglove не запустился за 15 секунд.") self._finish_error("Локальный мост визуализации не запустился за 15 секунд.")
source_done.set() source_done.set()
self._stop_event.set() self._stop_event.set()
join_publisher()
return
if publisher_aborted.is_set():
source_done.set()
self._stop_event.set()
join_publisher()
return return
if publisher_error: if publisher_error:
self._finish_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() source_done.set()
join_publisher()
return return
final_message = "Поток остановлен." final_message = "Поток остановлен."
@ -295,11 +401,8 @@ class VisualizationRuntime:
self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}") self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}")
finally: finally:
source_done.set() source_done.set()
publisher.join(timeout=15.0) join_publisher()
if publisher.is_alive(): if publisher_error:
self._stop_event.set()
self._finish_error("Очередь публикации не завершилась за 15 секунд.")
elif publisher_error:
self._finish_error( self._finish_error(
f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}" f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
) )
@ -330,6 +433,10 @@ class VisualizationRuntime:
self._foxglove_viewer_url = None self._foxglove_viewer_url = None
self._notify() self._notify()
def _current_scene_settings(self) -> RerunSceneSettings:
with self._lock:
return self._scene_settings
def _notify(self) -> None: def _notify(self) -> None:
callback = self._on_state_change callback = self._on_state_change
if callback is not None: if callback is not None:
@ -356,7 +463,7 @@ def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: flo
"schema_version": 1, "schema_version": 1,
"started_at_utc": started_at_utc, "started_at_utc": started_at_utc,
"started_monotonic_ns": time.monotonic_ns(), "started_monotonic_ns": time.monotonic_ns(),
"operation": "k1_live_mqtt_to_foxglove", "operation": "k1_live_mqtt_to_rerun",
"target": "owner-controlled K1 at redacted RFC1918 address", "target": "owner-controlled K1 at redacted RFC1918 address",
"requested_duration_seconds": duration_seconds, "requested_duration_seconds": duration_seconds,
"raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt", "raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt",
@ -366,7 +473,7 @@ def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: flo
notes = ( notes = (
"# K1 live visualization session\n\n" "# K1 live visualization session\n\n"
f"Started UTC: {started_at_utc}\n\n" f"Started UTC: {started_at_utc}\n\n"
"The local control API started a read-only MQTT subscription and Foxglove " "The local control API started a read-only MQTT subscription and Rerun "
"preview. Raw MQTT evidence is written before preview decoding. The target " "preview. Raw MQTT evidence is written before preview decoding. The target "
f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n" f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n"
) )

View File

@ -2,10 +2,11 @@ from __future__ import annotations
import asyncio import asyncio
import threading import threading
from collections.abc import Mapping from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
from bleak.exc import BleakError from bleak.exc import BleakError
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
@ -17,6 +18,7 @@ from k1link.artifacts import write_json_atomic
from k1link.ble.scanner import scan from k1link.ble.scanner import scan
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
from k1link.mqtt import validate_private_ipv4 from k1link.mqtt import validate_private_ipv4
from k1link.viewer.rerun_bridge import RerunSceneSettings
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
REPOSITORY_ROOT = Path(__file__).resolve().parents[3] REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
@ -43,6 +45,17 @@ class ReplayRequest(BaseModel):
loop: bool = False loop: bool = False
class ViewerSettingsRequest(BaseModel):
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
accumulation_seconds: float = Field(default=12.0, ge=0.0, le=120.0)
show_points: bool = True
show_trajectory: bool = True
show_grid: bool = True
class ConsoleService: class ConsoleService:
def __init__(self, repository_root: Path) -> None: def __init__(self, repository_root: Path) -> None:
self.repository_root = repository_root.resolve() self.repository_root = repository_root.resolve()
@ -80,13 +93,13 @@ class ConsoleService:
message = runtime["message"] message = runtime["message"]
elif selected_device_id is not None: elif selected_device_id is not None:
phase = "device_selected" phase = "device_selected"
message = "K1 выбран. Теперь введите название и пароль Wi-Fi." message = "Устройство выбрано. Теперь введите название и пароль Wi-Fi."
elif devices: elif devices:
likely_count = sum(bool(item.get("likely_k1")) for item in devices) likely_count = sum(bool(item.get("likely_k1")) for item in devices)
phase = "idle" phase = "idle"
message = ( message = (
f"Найдено BLE-устройств: {len(devices)}. " f"Найдено BLE-устройств: {len(devices)}. "
f"Возможных K1: {likely_count}. Выберите нужное устройство." f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
) )
else: else:
phase = "idle" phase = "idle"
@ -100,6 +113,8 @@ class ConsoleService:
"k1_ip": k1_ip, "k1_ip": k1_ip,
"foxglove_ws_url": runtime["foxglove_ws_url"], "foxglove_ws_url": runtime["foxglove_ws_url"],
"foxglove_viewer_url": runtime["foxglove_viewer_url"], "foxglove_viewer_url": runtime["foxglove_viewer_url"],
"rerun_grpc_url": runtime["rerun_grpc_url"],
"viewer_settings": runtime["viewer_settings"],
"source_mode": runtime["source_mode"], "source_mode": runtime["source_mode"],
"metrics": { "metrics": {
"pipeline_ms": metrics["mqtt_to_publish_ms"], "pipeline_ms": metrics["mqtt_to_publish_ms"],
@ -144,7 +159,7 @@ class ConsoleService:
raise ValueError("сначала найдите и выберите устройство через Bluetooth") raise ValueError("сначала найдите и выберите устройство через Bluetooth")
self._set_operation( self._set_operation(
"provisioning", "provisioning",
"Передаём в K1 настройки Wi-Fi одним подтверждённым запросом.", "Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
) )
session_dir = _new_operation_session_dir( session_dir = _new_operation_session_dir(
self.repository_root, self.repository_root,
@ -177,12 +192,14 @@ class ConsoleService:
) )
if ipv4 is None: if ipv4 is None:
raise RuntimeError( raise RuntimeError(
"K1 не сообщил адрес в локальной сети; автоматического повтора не было" "Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
) )
with self._lock: with self._lock:
self._selected_device_id = request.device_id self._selected_device_id = request.device_id
self._k1_ip = ipv4 self._k1_ip = ipv4
self._operation_message = "K1 подключён к Wi-Fi и сообщил локальный адрес." self._operation_message = (
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
)
finally: finally:
password = "" password = ""
with self._lock: with self._lock:
@ -193,7 +210,7 @@ class ConsoleService:
target = host or self.state()["k1_ip"] target = host or self.state()["k1_ip"]
if not isinstance(target, str) or not target: if not isinstance(target, str) or not target:
raise ValueError( raise ValueError(
"сначала подключите K1 к Wi-Fi или укажите его локальный адрес" "сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
) )
target = validate_private_ipv4(target) target = validate_private_ipv4(target)
out_dir = new_live_session_dir(self.repository_root) out_dir = new_live_session_dir(self.repository_root)
@ -211,6 +228,21 @@ class ConsoleService:
self.runtime.stop() self.runtime.stop()
return self.state() return self.state()
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
self.runtime.update_scene_settings(
RerunSceneSettings(
point_size=request.point_size,
color_mode=request.color_mode,
palette=request.palette,
custom_color=request.custom_color,
accumulation_seconds=request.accumulation_seconds,
show_points=request.show_points,
show_trajectory=request.show_trajectory,
show_grid=request.show_grid,
)
)
return self.state()
def _set_operation(self, phase: str, message: str) -> None: def _set_operation(self, phase: str, message: str) -> None:
with self._lock: with self._lock:
self._operation_phase = phase self._operation_phase = phase
@ -218,12 +250,23 @@ class ConsoleService:
service = ConsoleService(REPOSITORY_ROOT) service = ConsoleService(REPOSITORY_ROOT)
@asynccontextmanager
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
try:
yield
finally:
service.runtime.close()
app = FastAPI( app = FastAPI(
title="NODE.DC K1 Live Console API", title="NODE.DC Device Control API",
version=__version__, version=__version__,
docs_url="/api/docs", docs_url="/api/docs",
redoc_url=None, redoc_url=None,
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json",
lifespan=app_lifespan,
) )
@ -257,7 +300,7 @@ async def connect(request: ConnectRequest) -> dict[str, Any]:
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc: except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
raise HTTPException( raise HTTPException(
status_code=502, status_code=502,
detail=f"Ошибка подключения K1 к Wi-Fi: {exc}", detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
) from exc ) from exc
@ -282,6 +325,11 @@ def stop_session() -> dict[str, Any]:
return service.stop() return service.stop()
@app.post("/api/viewer/settings")
def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
return service.update_viewer_settings(request)
@app.websocket("/api/events") @app.websocket("/api/events")
async def events(websocket: WebSocket) -> None: async def events(websocket: WebSocket) -> None:
await websocket.accept() await websocket.accept()

281
tests/test_rerun_bridge.py Normal file
View File

@ -0,0 +1,281 @@
from __future__ import annotations
import json
import socket
import struct
import threading
import time
from pathlib import Path
import numpy as np
import pytest
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.viewer.messages import StreamMessage
from k1link.viewer.rerun_bridge import RerunBridge, RerunSceneSettings, _point_colors
from k1link.viewer.runtime import VisualizationRuntime
class FakeRecording:
def __init__(self) -> None:
self.logs: list[tuple[str, object, bool]] = []
self.times: list[tuple[str, dict[str, object]]] = []
self.blueprints: list[object] = []
self.disconnected = False
def serve_grpc(self, **_: object) -> str:
return "rerun+http://127.0.0.1:9876/proxy"
def log(self, path: str, entity: object, *, static: bool = False) -> None:
self.logs.append((path, entity, static))
def set_time(self, timeline: str, **value: object) -> None:
self.times.append((timeline, value))
def send_blueprint(self, blueprint: object, **_: object) -> None:
self.blueprints.append(blueprint)
def disconnect(self) -> None:
self.disconnected = True
def flush(self, **_: object) -> None:
return
def _message(topic: str, payload: bytes, *, sequence: int = 7) -> StreamMessage:
return StreamMessage(
sequence=sequence,
topic=topic,
payload=payload,
received_at_epoch_ns=1_784_124_315_186_225_000,
received_monotonic_ns=None,
source="test",
)
def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
recording = FakeRecording()
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
point_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
pose_payload = struct.pack(
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
)
bridge.process(_message("RealtimePointcloud", point_payload))
bridge.process(_message("RealtimePath", pose_payload, sequence=8))
paths = [path for path, _, _ in recording.logs]
snapshot = bridge.metrics.snapshot()
assert bridge.grpc_url == "rerun+http://127.0.0.1:9876/proxy"
assert "/world/points" in paths
assert "/world/sensor_pose" in paths
assert "/world/trajectory" in paths
assert snapshot["pcl_frames"] == 1
assert snapshot["pose_frames"] == 1
assert snapshot["last_point_count"] == 1
assert {timeline for timeline, _ in recording.times} == {
"capture_time",
"message_sequence",
"stream_time",
}
bridge.close()
assert recording.disconnected is True
def test_bad_frame_is_counted_without_publishing() -> None:
recording = FakeRecording()
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
bridge.process(_message("RealtimePointcloud", b"short"))
snapshot = bridge.metrics.snapshot()
assert snapshot["messages_received"] == 1
assert snapshot["decode_errors"] == 1
assert not any(path == "/world/points" for path, _, _ in recording.logs)
def test_palettes_are_deterministic_and_custom_color_is_exact() -> None:
positions = np.asarray([[0, 0, 0], [0, 0, 10]], dtype=np.float32)
intensities = np.asarray([0, 255], dtype=np.uint8)
height = _point_colors(
positions,
intensities,
None,
RerunSceneSettings(color_mode="height", palette="viridis"),
)
custom = _point_colors(
positions,
intensities,
None,
RerunSceneSettings(color_mode="class", custom_color="#102030"),
)
assert height.shape == (2, 3)
assert height.dtype == np.uint8
assert height[0].tolist() == [68, 1, 84]
assert height[1].tolist() == [253, 231, 37]
assert custom.tolist() == [[16, 32, 48], [16, 32, 48]]
def test_real_grpc_server_releases_its_port() -> None:
probe = socket.socket()
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
probe.close()
bridge = RerunBridge(
grpc_port=port,
cors_allow_origin=("http://127.0.0.1:8000",),
)
assert bridge.grpc_url == f"rerun+http://127.0.0.1:{port}/proxy"
bridge.close()
deadline = time.monotonic() + 2.0
while True:
available = socket.socket()
try:
available.bind(("127.0.0.1", port))
break
except OSError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
finally:
available.close()
def test_runtime_exposes_rerun_url_and_stops_cleanly(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
point_topic = "RealtimePointcloud"
pose_topic = "RealtimePath"
point_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
pose_payload = struct.pack(
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
)
frames = bytearray(RAW_MAGIC)
for topic, payload in ((point_topic, point_payload), (pose_topic, pose_payload)):
topic_raw = topic.encode()
frames.extend(FRAME_HEADER.pack(len(topic_raw), len(payload)))
frames.extend(topic_raw)
frames.extend(payload)
capture.write_bytes(frames)
metadata = [
{
"record_type": "message",
"sequence": 1,
"received_at_epoch_ns": 1_000_000_000,
"received_monotonic_ns": 1_000_000_000,
},
{
"record_type": "message",
"sequence": 2,
"received_at_epoch_ns": 11_000_000_000,
"received_monotonic_ns": 11_000_000_000,
},
]
(tmp_path / "mqtt.metadata.jsonl").write_text(
"".join(json.dumps(item) + "\n" for item in metadata),
encoding="utf-8",
)
recording = FakeRecording()
created: list[RerunBridge] = []
def bridge_factory(**kwargs: object) -> RerunBridge:
bridge = RerunBridge(
recording_factory=lambda _: recording, # type: ignore[arg-type]
**kwargs, # type: ignore[arg-type]
)
created.append(bridge)
return bridge
runtime = VisualizationRuntime(
bridge_factory=bridge_factory,
)
runtime.start_replay(capture, speed=1.0)
deadline = time.monotonic() + 5.0
snapshot = runtime.snapshot()
while time.monotonic() < deadline:
snapshot = runtime.snapshot()
if snapshot["rerun_grpc_url"] and snapshot["metrics"]["pcl_frames"] == 1:
break
time.sleep(0.05)
assert snapshot["rerun_grpc_url"] == "rerun+http://127.0.0.1:9876/proxy"
assert snapshot["metrics"]["pcl_frames"] == 1
assert snapshot["metrics"]["mqtt_to_publish_ms"] is None
runtime.stop(wait_seconds=5.0)
assert runtime.snapshot()["phase"] == "idle"
assert runtime.snapshot()["rerun_grpc_url"] == "rerun+http://127.0.0.1:9876/proxy"
assert recording.disconnected is False
runtime.start_replay(capture, speed=0.0)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if runtime.snapshot()["metrics"]["pcl_frames"] == 1:
break
time.sleep(0.05)
runtime.stop(wait_seconds=5.0)
assert len(created) == 1
assert runtime.snapshot()["metrics"]["pcl_frames"] == 1
runtime.close()
assert runtime.snapshot()["rerun_grpc_url"] is None
assert recording.disconnected is True
def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
topic = b"RealtimePointcloud"
payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
capture.write_bytes(
RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload
)
(tmp_path / "mqtt.metadata.jsonl").write_text(
json.dumps(
{
"record_type": "message",
"sequence": 1,
"received_at_epoch_ns": 1_000_000_000,
"received_monotonic_ns": 1_000_000_000,
}
)
+ "\n",
encoding="utf-8",
)
factory_entered = threading.Event()
release_factory = threading.Event()
recording = FakeRecording()
def blocked_factory(**kwargs: object) -> RerunBridge:
factory_entered.set()
assert release_factory.wait(timeout=5.0)
return RerunBridge(
recording_factory=lambda _: recording, # type: ignore[arg-type]
**kwargs, # type: ignore[arg-type]
)
runtime = VisualizationRuntime(bridge_factory=blocked_factory)
runtime.start_replay(capture, speed=0.0)
assert factory_entered.wait(timeout=2.0)
runtime.close(wait_seconds=0.01)
release_factory.set()
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and not recording.disconnected:
time.sleep(0.01)
runtime.close(wait_seconds=1.0)
assert recording.disconnected is True
assert runtime.snapshot()["rerun_grpc_url"] is None
with pytest.raises(RuntimeError, match="runtime завершён"):
runtime.start_replay(capture, speed=0.0)

View File

@ -46,3 +46,30 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
] ]
assert [device["likely_k1"] for device in state["devices"]] == [True, False] assert [device["likely_k1"] for device in state["devices"]] == [True, False]
def test_viewer_settings_are_validated_and_exposed(tmp_path: Path) -> None:
service = web_app.ConsoleService(tmp_path)
request = web_app.ViewerSettingsRequest(
point_size=4.0,
color_mode="height",
palette="viridis",
custom_color="#102030",
accumulation_seconds=18,
show_points=True,
show_trajectory=False,
show_grid=False,
)
state = service.update_viewer_settings(request)
assert state["rerun_grpc_url"] is None
assert state["viewer_settings"] == {
"point_size": 4.0,
"color_mode": "height",
"palette": "viridis",
"custom_color": "#102030",
"accumulation_seconds": 18.0,
"show_points": True,
"show_trajectory": False,
"show_grid": False,
}

97
uv.lock
View File

@ -33,6 +33,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
] ]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]] [[package]]
name = "bleak" name = "bleak"
version = "3.0.2" version = "3.0.2"
@ -269,6 +278,7 @@ dependencies = [
{ name = "foxglove-sdk" }, { name = "foxglove-sdk" },
{ name = "lz4" }, { name = "lz4" },
{ name = "paho-mqtt" }, { name = "paho-mqtt" },
{ name = "rerun-sdk" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" }, { name = "typer" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
@ -288,6 +298,7 @@ requires-dist = [
{ name = "foxglove-sdk", specifier = "==0.25.3" }, { name = "foxglove-sdk", specifier = "==0.25.3" },
{ name = "lz4", specifier = ">=4.4,<5" }, { name = "lz4", specifier = ">=4.4,<5" },
{ name = "paho-mqtt", specifier = ">=2.1,<3" }, { name = "paho-mqtt", specifier = ">=2.1,<3" },
{ name = "rerun-sdk", specifier = "==0.34.1" },
{ name = "rich", specifier = ">=13.9,<15" }, { name = "rich", specifier = ">=13.9,<15" },
{ name = "typer", specifier = ">=0.15,<1" }, { name = "typer", specifier = ">=0.15,<1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
@ -300,6 +311,25 @@ dev = [
{ name = "ruff", specifier = ">=0.11,<1" }, { name = "ruff", specifier = ">=0.11,<1" },
] ]
[[package]]
name = "numpy"
version = "2.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" },
{ url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" },
{ url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" },
{ url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" },
{ url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" },
{ url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" },
{ url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" },
{ url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" },
{ url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.2"
@ -327,6 +357,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
] ]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -336,6 +383,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "psutil"
version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "pyarrow"
version = "25.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" },
{ url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" },
{ url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" },
{ url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" },
{ url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" },
{ url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"
@ -480,6 +558,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
] ]
[[package]]
name = "rerun-sdk"
version = "0.34.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "numpy" },
{ name = "pillow" },
{ name = "psutil" },
{ name = "pyarrow" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/37/41422eca73b5f933872ad073d17034f47ccac730fc4a29b9064d73c74424/rerun_sdk-0.34.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:32d3fb3e9f46eb84433427dc6add6ef7508bbd36295dcc80d693dccacf936e6b", size = 133584987, upload-time = "2026-07-07T17:43:14.934Z" },
{ url = "https://files.pythonhosted.org/packages/24/f7/c2e5097f0138a3c4d70e2fe1c24781fac9aec351324c2ea5b15f4d6971b8/rerun_sdk-0.34.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:57585f9c77af6d8a5ee0342bd79b87431b632bf7158e9b8dd4756d6ccd7b7feb", size = 142889929, upload-time = "2026-07-07T17:43:20.293Z" },
{ url = "https://files.pythonhosted.org/packages/18/fa/f87899a8c0cc36901d32069072340b894654fa6f3b3a2a74913565c3661a/rerun_sdk-0.34.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:60c35adb49f04bd64bf1a426b59f61aeee8312fa7cc2624878b43186e817b2d4", size = 147505605, upload-time = "2026-07-07T17:43:25.397Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3a/07125af48ef024c573acadf1bfedd406bcc838ffa1af0d7b20bd79895267/rerun_sdk-0.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:3448b34d385994a32caaa4e3ffd42b69069ab7791382d8936089e4928ca24cd6", size = 126554967, upload-time = "2026-07-07T17:43:30.61Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.3.4" version = "14.3.4"