Compare commits

..

No commits in common. "904c6ea13add0f5960ccaa4806d0ae9b16a76873" and "8b5b90d8af2580362d37c98295659e48b09bc9df" have entirely different histories.

37 changed files with 1889 additions and 6756 deletions

View File

@ -7,10 +7,8 @@ or speculative writes.
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
the scan-correlated point-cloud and pose streams, and decoded both successfully.
The repository also contains a local React control console, an automatic Rerun
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 also contains a local React control console and a Foxglove live
bridge for the verified point-cloud and trajectory topics.
The repository now contains one narrowly gated state-changing command:
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
@ -57,77 +55,45 @@ uv run k1link doctor
uv run pytest
```
## NODE.DC Control Station and visualization adapters
## Live console and Foxglove
The browser application is now a universal NODE.DC Control Station rather than
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter.
Install, type-check, build and serve the complete local application from the
repository root:
Build the browser control surface once, then launch the whole local stand from
the repository root:
```bash
uv sync --group dev
cd apps/k1-viewer
npm install
npm run typecheck
npm run build
cd ../..
uv run k1link serve
```
Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
plane and credential endpoint bind to loopback only. The current K1 adapter
still provides real CoreBluetooth discovery, one operator-triggered reviewed
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
reviewed-TSV replay, raw-first evidence storage and measured preview metrics.
Physical K1 scanning is still started and stopped by the verified double-click;
the connector publishes no modeling command.
Open `http://127.0.0.1:8000`. The API, credential form and Foxglove WebSocket
bind to loopback only. The console supports:
The Observation spatial workspace embeds the open-source Rerun Web Viewer
inside the NODE.DC shell. It can open a compatible RRD file over HTTP(S) or a
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
use an external hosted viewer UI.
- real CoreBluetooth K1 discovery;
- one operator-triggered reviewed BLE Wi-Fi provisioning write;
- live read-only MQTT capture with raw-first evidence storage;
- replay of native `.k1mqtt` evidence and the reviewed four-column TSV capture;
- `/k1/points`, `/k1/pose`, `/k1/trajectory` and `/k1/metrics` for Foxglove;
- measured Mac-side MQTT-receive-to-Foxglove-publish latency and bounded
latest-wins preview dropping.
The first K1 live session or replay in a `k1link serve` process creates one local
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
the resulting URL through control-plane state. Later sessions reset their
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.
For live mode, start the session in the console and use the verified physical
double-click on K1 to start or stop scanning. The connector deliberately does
not publish modeling commands yet. For the recorded proof, replay this ignored
local file at `1x` with loop enabled:
The default Rerun blueprint shows a 12-second sliding accumulation of real point
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.
```text
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
```
A powered-device checkpoint passed 80 real MQTT messages through the current
Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last
cloud and zero decode errors. Raw panoramic camera frames remain absent. Rerun
`capture_time` is the Mac receive timestamp, not a proven K1 sensor timestamp or
photon-to-screen measurement.
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.
Foxglove remains the full 3D workspace rather than being reimplemented in the
React console. In its 3D panel, select `/k1/points`, color by `intensity` (or
`z`/`<distance>`) and choose Turbo, Rainbow or a custom gradient. Add
`/k1/trajectory` for the cyan path and `/k1/pose` for the current pose. See the
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) for timing semantics and current
limitations.
`doctor` is intentionally non-invasive. It checks the local Python environment
and reports external tools; it does not request Bluetooth permission, scan the
@ -169,7 +135,7 @@ present.
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
- [Live console and Foxglove runbook](docs/06_K1_LIVE_VIEWER.md)
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
- [Session manifest schema](schemas/session-manifest.schema.json)
- [Reference input provenance](docs/reference/README.md)

View File

@ -1,256 +1,51 @@
# Пункт управления NODE.DC
# K1 Live Console
Универсальный браузерный пункт управления NODE.DC на React 19, TypeScript и
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
device adapter, но структура интерфейса от него не зависит.
React 19 + TypeScript + Vite frontend for the local K1 bridge. The console handles
connection setup, source selection, transport status, latency metrics and handoff to
the Foxglove 3D viewer. It does not emulate scanner operations or generate placeholder
telemetry.
Внутри пространственной рабочей поверхности встроен открытый 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` репозитория.
Канонический путь от корня репозитория:
## Run locally
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
uv sync --group dev
cd apps/k1-viewer
npm install
npm run typecheck
npm run build
cd ../..
uv run k1link serve
```
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
Wi-Fi.
Для разработки интерфейса после запуска backend:
```bash
cd apps/k1-viewer
npm run dev
```
Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
`VITE_API_TARGET`. Preview production-сборки запускается командой
`npm run preview` на `http://127.0.0.1:4173`.
The development server listens on `http://127.0.0.1:5173` and proxies `/api` to
`http://127.0.0.1:8000`. Set `VITE_API_TARGET` in a local `.env` file to use a
different backend address. Production artifacts are built with `npm run build`.
## Операторский путь для текущего K1 adapter
The NODE.DC UI packages are consumed from the sibling `NODEDC_DESIGN_GUIDELINE`
checkout through `file:` dependencies. Their source is not copied into this app.
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.
## Backend contract
В проверочном 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 | Тело / назначение |
| Method | Route | Body / purpose |
| --- | --- | --- |
| `GET` | `/api/health` | Проверка локального сервиса. |
| `GET` | `/api/state` | Авторитетный snapshot состояния. |
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. |
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. |
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
| `POST` | `/api/session/stop` | Остановка активного источника. |
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
| `WS` | `/api/events` | Периодические snapshots для live UI. |
| `GET` | `/api/health` | Local service health |
| `GET` | `/api/state` | Authoritative console state |
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }` |
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }` |
| `POST` | `/api/session/live` | Optional `{ "host", "duration_seconds" }` |
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }` |
| `POST` | `/api/session/stop` | Stop the active source |
| `WS` | `/api/events` | State snapshots for live UI updates |
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
пустыми. OpenAPI доступен по `/api/docs`.
`/api/state` and state-changing responses may return the snapshot directly or as
`{ "state": { ... } }`. A snapshot exposes `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `foxglove_ws_url`, `foxglove_viewer_url`,
`source_mode` and `metrics`.
## Карта исходников frontend
The Wi-Fi password remains only in React memory, is sent in the JSON POST body, and is
cleared after the backend acknowledges a successful connection. It is never written to
local storage or included in a URL.
| Файл | Ответственность |
| --- | --- |
| `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 и рабочих поверхностей. |
## Explicit unavailable states
## Safety и чувствительные данные
- Control Station и credential endpoint доступны только на loopback.
- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
хотя автоматически возвращаемый 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
и безопасная документация.
- Missing metrics render as an em dash; no synthetic values are used.
- BLE scanning remains a real API action and reports HTTP/network errors visibly.
- The Foxglove button is disabled until the backend supplies
`foxglove_viewer_url`.
- Replay cannot start without a backend-local capture path.

View File

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

View File

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

View File

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

View File

@ -1,8 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 322 B

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,61 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,133 +0,0 @@
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");
if (!rootElement) {
throw new Error("Не найден корневой элемент пункта управления.");
throw new Error("K1 console root element is missing.");
}
rootElement.classList.add("nodedc-ui-root");

View File

@ -1,30 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,758 +0,0 @@
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

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

View File

@ -1,388 +0,0 @@
: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

@ -1,416 +0,0 @@
.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

@ -1,270 +0,0 @@
@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

@ -1,83 +0,0 @@
.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

@ -1,116 +0,0 @@
.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

@ -1,352 +0,0 @@
.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

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

View File

@ -1,550 +0,0 @@
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

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

View File

@ -1,11 +1,9 @@
# K1 live console and embedded Rerun bridge
# K1 live console and Foxglove bridge
Status: the Rerun alpha milestone is implemented for the verified firmware-3
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.
Status: alpha implementation for the verified firmware-3 MQTT streams. It uses
real K1 data and does not generate a placeholder cloud, pose or latency value.
## Active device-to-scene path
## Architecture
```text
K1 lio_pcl / lio_pose
@ -13,263 +11,139 @@ K1 lio_pcl / lio_pose
v
read-only MQTT subscription on TCP 1883
|
+--> raw .k1mqtt + JSONL + SHA-256 summary (written first)
+--> raw .k1mqtt + JSONL + SHA-256 summary (first)
|
v
bounded latest-wins preview queue (32 messages)
|
v
reviewed raw-LZ4/protobuf decoders
raw-LZ4/protobuf decoder --> foxglove.PointCloud / Pose / SceneUpdate
|
v
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
ws://127.0.0.1:8765 --> Foxglove 3D
React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
```
The Paho MQTT callback does not decode or render the point cloud. It first
writes and flushes the raw frame and metadata, then enqueues a preview message.
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.
The Paho MQTT callback never decodes the point cloud. It writes and flushes the
raw frame and metadata, then enqueues a preview reference. If visualization cannot
keep up, the oldest queued preview is discarded while the raw capture continues.
## Start the local application
Prerequisites are the repository-local Python environment and Node.js 20.19+ or
22.12+. No global Python package or system component is installed.
Prerequisites are the repository-local Python environment and Node.js 20 or
newer. No global Python package or system component is installed.
```bash
uv sync --group dev
cd apps/k1-viewer
npm install
npm run typecheck
npm run build
cd ../..
uv run k1link serve
```
Open `http://127.0.0.1:8000`. The static Control Station, REST API, WebSocket
state channel and credential endpoint bind to loopback. The Rerun gRPC server is
a separate listener with a different network boundary described under
[Network and security boundary](#network-and-security-boundary).
Open `http://127.0.0.1:8000`. `k1link serve` intentionally exposes no LAN bind
option because its provisioning endpoint temporarily receives a Wi-Fi password.
The password is accepted only in the POST body, is never logged or persisted by
the connector, and is cleared from the React form after success.
## Replay a reviewed capture
## Replay the captured proof
1. In **Парк → Локальное устройство**, choose the replay mode.
2. Enter a native `mqtt.raw.k1mqtt` capture or a reviewed four-column TSV,
for example:
1. In **Session**, select **Replay capture**.
2. Enter the repository-relative path:
```text
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
```
3. Select replay speed and optional looping, then start the session.
4. The runtime starts the process-wide Rerun `RecordingStream` and its
gRPC/proxy server on TCP 9876 before the first replay. Later sessions reuse it.
5. Open **Наблюдение → Пространственная сцена**. With the manual source field
empty, the embedded viewer automatically uses:
3. Use speed `1` and enable loop for initial viewer setup.
4. Start replay, then select **Open Foxglove 3D**.
5. In a Foxglove 3D panel, enable `/k1/points`, `/k1/pose` and
`/k1/trajectory`.
6. For the cloud, choose `intensity` as the color field and a Turbo/Rainbow
colormap or custom two-color gradient. Foxglove can also color by `x`, `y`,
`z` and its derived `<distance>`.
```text
rerun+http://127.0.0.1:9876/proxy
```
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.
The reviewed TSV reader accepts exactly four columns: receive timestamp, topic,
declared payload length and hex payload. It verifies timestamp, UTF-8 topic,
declared length, hex encoding and allocation bounds. Native
`mqtt.raw.k1mqtt` captures are supported directly and use their sibling metadata
timestamps when present.
## Connect and stream live
1. Power K1 to its normal steady-green standby state.
2. Confirm the manual power checklist in **Парк → Локальное устройство**.
3. Run the real six-second BLE scan and select the intended device from the
complete visible-device list.
4. Enter the existing router SSID/password and explicitly authorize the reviewed
provisioning write. The backend does not retry the write automatically.
5. When K1 reports a non-AP private address, start live reception.
6. Wait until the UI reports that the local Rerun bridge is ready. No manual
viewer URL is needed.
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.
2. Confirm the manual power checklist in **Connect**.
3. Run the real six-second BLE scan and select the K1 candidate.
4. Enter the existing router SSID/password and press **Provision Wi-Fi &
connect**. That button is the explicit authorization for one reviewed 99-byte
provisioning write; the backend never retries automatically.
5. When K1 reports a non-AP private address, press **Start live stream**.
6. Open Foxglove before scanning if convenient.
7. Double-click the physical K1 button to start scanning. Double-click again to
stop, wait for the LED to return to steady green, then stop the local session.
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
hash summary. The connector subscribes to the fixed report-topic allowlist and
does not publish an application request or modeling command.
hash summary. No application request topic is published.
## Automatic Rerun source and lifecycle
## Published topics
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 |
| Topic | Foxglove schema | Meaning |
| --- | --- | --- |
| `/world` | `ViewCoordinates` | right-handed Z-up display convention |
| `/world/points` | `Points3D` | decoded metric XYZ with computed or available RGB colors |
| `/world/sensor_pose` | `Transform3D` + `TransformAxes3D` | current decoded translation, xyzw quaternion and pose axes |
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 20,000 decoded poses |
| `/k1/points` | `foxglove.PointCloud` | metric XYZ float32 + uint8 intensity, stride 16 |
| `/k1/pose` | `foxglove.PoseInFrame` | current decoded K1 pose in the `map` frame |
| `/k1/trajectory` | `foxglove.SceneUpdate` | bounded cyan line strip, throttled while growing |
| `/k1/metrics` | JSON schema | latency, decode/publish time, FPS, points and drops |
Point count, frame rate, queue drops and measured pipeline time remain product
metrics in the outer Control Station; they are not logged as Rerun entities and
therefore cannot create additional automatic viewer panes.
Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as verified.
No axis swap, quaternion normalization or vehicle extrinsic is silently applied.
Only the low byte of `rgbi` is labeled intensity; interpreting the upper bytes
as RGB remains unverified.
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.
## Latency semantics
Only the low byte of firmware-3 `rgbi` has been verified as intensity. The
upper bytes are not labeled RGB. Legacy frames use RGB only when those decoded
fields are actually present.
`pipeline_ms` / `mqtt_to_publish_ms` is measured with the host monotonic clock
from MQTT callback receipt through raw disk write, bounded queue wait, decode,
packing and Foxglove channel publish. Rolling p50/p95 values use the last 512
published decoded messages.
## Scene controls
The current product controls call `POST /api/viewer/settings`; backend state is
authoritative and the bridge applies the updated settings to subsequent
messages.
| 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.
This is the exact Mac pipeline latency, not yet sensor-photon-to-screen latency.
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
clock-correlated device timestamp or high-speed-camera experiment. Foxglove
message time therefore uses host receive Unix time while durations use
`monotonic_ns`.
## Current boundaries
- Raw panoramic camera frames were absent from the observed MQTT report topics.
This milestone contains point cloud and pose/trajectory; operational metrics
are rendered by the outer Control Station.
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
- Raw panoramic camera frames were not present on the observed MQTT report
topics; this milestone intentionally ships point cloud plus trajectory only.
- Physical double-click remains the start/stop control. A future MQTT modeling
publisher needs a separately reviewed state-changing profile.
- No terrain map, elevation model, obstacle segmentation, localization fusion,
mission planner or vehicle control is implemented by this viewer milestone.
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
calibration task.
- 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.
- The React application opens the official Foxglove viewer over a local
WebSocket. It does not embed or fork the proprietary modern viewer; Foxglove
account/seat terms apply to that viewer independently of this connector.
- Exact path axes and a scanner-to-vehicle transform must be calibrated before
mounting on the unmanned platform.
## Verification checkpoint — 2026-07-16
## Verification checkpoint — 2026-07-15
A powered K1 capture was passed through the active MQTT → decoder → Rerun gRPC →
embedded Web Viewer path without a viewer-side substitute:
The full captured 180-second scan was passed through the production Foxglove
bridge without a viewer-side substitute:
| Check | Result |
| --- | ---: |
| real MQTT messages processed | 80 |
| `lio_pcl` frames published | 38 |
| `lio_pose` frames published | 42 |
| points in the last cloud | 2,775 |
| MQTT messages read | 2,836 |
| `lio_pcl` frames published | 1,140 |
| `lio_pose` frames published | 1,215 |
| points packed and published | 4,165,862 |
| decoder errors | 0 |
| final trajectory poses | 1,215 |
The 38 point frames and 42 pose frames account for all 80 observed messages.
This proves current real-device decoding, bridge publication and embedded-viewer
delivery for point cloud plus trajectory. It does not prove a camera channel or
sensor-to-screen latency.
## 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.
The same replay was then started through the FastAPI/React contract at `10x`.
The API exposed the loopback Foxglove URL, live point/pose metrics and non-zero
preview-drop accounting under deliberate acceleration, and released TCP 8765
after the stop request. This validates replay, overload behavior and lifecycle;
the next checkpoint is the powered K1 ideal-LAN live run.

View File

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

View File

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

View File

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

View File

@ -1,281 +0,0 @@
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,30 +46,3 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
]
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,15 +33,6 @@ 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" },
]
[[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]]
name = "bleak"
version = "3.0.2"
@ -278,7 +269,6 @@ dependencies = [
{ name = "foxglove-sdk" },
{ name = "lz4" },
{ name = "paho-mqtt" },
{ name = "rerun-sdk" },
{ name = "rich" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
@ -298,7 +288,6 @@ requires-dist = [
{ name = "foxglove-sdk", specifier = "==0.25.3" },
{ name = "lz4", specifier = ">=4.4,<5" },
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
{ name = "rerun-sdk", specifier = "==0.34.1" },
{ name = "rich", specifier = ">=13.9,<15" },
{ name = "typer", specifier = ">=0.15,<1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
@ -311,25 +300,6 @@ dev = [
{ 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]]
name = "packaging"
version = "26.2"
@ -357,23 +327,6 @@ 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" },
]
[[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]]
name = "pluggy"
version = "1.6.0"
@ -383,37 +336,6 @@ 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" },
]
[[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]]
name = "pydantic"
version = "2.13.4"
@ -558,25 +480,6 @@ 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" },
]
[[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]]
name = "rich"
version = "14.3.4"