Compare commits
No commits in common. "904c6ea13add0f5960ccaa4806d0ae9b16a76873" and "8b5b90d8af2580362d37c98295659e48b09bc9df" have entirely different histories.
904c6ea13a
...
8b5b90d8af
90
README.md
90
README.md
|
|
@ -7,10 +7,8 @@ or speculative writes.
|
||||||
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
|
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
|
||||||
K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured
|
K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured
|
||||||
the scan-correlated point-cloud and pose streams, and decoded both successfully.
|
the scan-correlated point-cloud and pose streams, and decoded both successfully.
|
||||||
The repository also contains a local React control console, an automatic Rerun
|
The repository also contains a local React control console and a Foxglove live
|
||||||
gRPC bridge for the verified point-cloud and trajectory streams, and a
|
bridge for the verified point-cloud and trajectory topics.
|
||||||
self-hosted Rerun Web Viewer embedded in that console. The former Foxglove
|
|
||||||
bridge remains only as a legacy regression module.
|
|
||||||
|
|
||||||
The repository now contains one narrowly gated state-changing command:
|
The repository now contains one narrowly gated state-changing command:
|
||||||
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
|
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
|
||||||
|
|
@ -57,77 +55,45 @@ uv run k1link doctor
|
||||||
uv run pytest
|
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
|
Build the browser control surface once, then launch the whole local stand from
|
||||||
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
|
the repository root:
|
||||||
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
|
|
||||||
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter.
|
|
||||||
|
|
||||||
Install, type-check, build and serve the complete local application from the
|
|
||||||
repository root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --group dev
|
|
||||||
cd apps/k1-viewer
|
cd apps/k1-viewer
|
||||||
npm install
|
npm install
|
||||||
npm run typecheck
|
|
||||||
npm run build
|
npm run build
|
||||||
cd ../..
|
cd ../..
|
||||||
uv run k1link serve
|
uv run k1link serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
|
Open `http://127.0.0.1:8000`. The API, credential form and Foxglove WebSocket
|
||||||
plane and credential endpoint bind to loopback only. The current K1 adapter
|
bind to loopback only. The console supports:
|
||||||
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.
|
|
||||||
|
|
||||||
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
- real CoreBluetooth K1 discovery;
|
||||||
inside the NODE.DC shell. It can open a compatible RRD file over HTTP(S) or a
|
- one operator-triggered reviewed BLE Wi-Fi provisioning write;
|
||||||
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
|
- live read-only MQTT capture with raw-first evidence storage;
|
||||||
use an external hosted viewer UI.
|
- 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
|
For live mode, start the session in the console and use the verified physical
|
||||||
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
|
double-click on K1 to start or stop scanning. The connector deliberately does
|
||||||
the resulting URL through control-plane state. Later sessions reset their
|
not publish modeling commands yet. For the recorded proof, replay this ignored
|
||||||
session-local scene and metrics and reuse that process-wide stream; this avoids
|
local file at `1x` with loop enabled:
|
||||||
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.
|
|
||||||
|
|
||||||
The default Rerun blueprint shows a 12-second sliding accumulation of real point
|
```text
|
||||||
frames. Product controls are connected for point size, intensity/height/distance
|
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
|
||||||
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.
|
|
||||||
|
|
||||||
A powered-device checkpoint passed 80 real MQTT messages through the current
|
Foxglove remains the full 3D workspace rather than being reimplemented in the
|
||||||
Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last
|
React console. In its 3D panel, select `/k1/points`, color by `intensity` (or
|
||||||
cloud and zero decode errors. Raw panoramic camera frames remain absent. Rerun
|
`z`/`<distance>`) and choose Turbo, Rainbow or a custom gradient. Add
|
||||||
`capture_time` is the Mac receive timestamp, not a proven K1 sensor timestamp or
|
`/k1/trajectory` for the cyan path and `/k1/pose` for the current pose. See the
|
||||||
photon-to-screen measurement.
|
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) for timing semantics and current
|
||||||
|
limitations.
|
||||||
The old Foxglove implementation is retained only in
|
|
||||||
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current
|
|
||||||
live/replay runtime does not start it or use TCP 8765. The
|
|
||||||
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
|
|
||||||
its timing/security boundaries; the frontend contract is documented in
|
|
||||||
[`apps/k1-viewer/README.md`](apps/k1-viewer/README.md).
|
|
||||||
|
|
||||||
The FastAPI application and credential endpoint bind to loopback, but the Rerun
|
|
||||||
gRPC server currently binds TCP 9876 on all network interfaces even though its
|
|
||||||
reported source URL contains `127.0.0.1`. It has no connector-level
|
|
||||||
authentication or TLS. Use it only on a trusted laboratory LAN, do not expose
|
|
||||||
9876 to the public Internet or a cellular WAN, and add an authenticated secure
|
|
||||||
proxy before any remote deployment. Stopping acquisition keeps the local scene
|
|
||||||
server and its URL available for the next session. Stop `k1link serve` to close
|
|
||||||
the listener and release its retained memory.
|
|
||||||
|
|
||||||
`doctor` is intentionally non-invasive. It checks the local Python environment
|
`doctor` is intentionally non-invasive. It checks the local Python environment
|
||||||
and reports external tools; it does not request Bluetooth permission, scan the
|
and reports external tools; it does not request Bluetooth permission, scan the
|
||||||
|
|
@ -169,7 +135,7 @@ present.
|
||||||
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
|
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
|
||||||
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
|
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
|
||||||
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
|
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
|
||||||
- [Live console and 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)
|
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||||
- [Reference input provenance](docs/reference/README.md)
|
- [Reference input provenance](docs/reference/README.md)
|
||||||
|
|
|
||||||
|
|
@ -1,256 +1,51 @@
|
||||||
# Пункт управления NODE.DC
|
# K1 Live Console
|
||||||
|
|
||||||
Универсальный браузерный пункт управления NODE.DC на React 19, TypeScript и
|
React 19 + TypeScript + Vite frontend for the local K1 bridge. The console handles
|
||||||
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
|
connection setup, source selection, transport status, latency metrics and handoff to
|
||||||
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
|
the Foxglove 3D viewer. It does not emulate scanner operations or generate placeholder
|
||||||
device adapter, но структура интерфейса от него не зависит.
|
telemetry.
|
||||||
|
|
||||||
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
|
## Run locally
|
||||||
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
|
|
||||||
переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
|
|
||||||
создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
|
|
||||||
потока или совместимой RRD-записи.
|
|
||||||
|
|
||||||
## Текущее состояние
|
|
||||||
|
|
||||||
| Контур | Состояние | Что это означает |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| NODE.DC fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
|
|
||||||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
|
||||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
|
||||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
|
||||||
| Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. |
|
|
||||||
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
|
||||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. |
|
|
||||||
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
|
||||||
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
|
|
||||||
|
|
||||||
Приложение не генерирует демонстрационное облако, траекторию, кадры или
|
|
||||||
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
|
|
||||||
показывают `—`.
|
|
||||||
|
|
||||||
## Архитектура данных
|
|
||||||
|
|
||||||
```text
|
|
||||||
K1 MQTT :1883, read-only
|
|
||||||
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
|
|
||||||
└── bounded latest-wins preview queue (32 сообщения)
|
|
||||||
└── проверенный protobuf/LZ4 decoder
|
|
||||||
└── Rerun Points3D + Transform3D + LineStrips3D
|
|
||||||
└── gRPC/proxy TCP 9876
|
|
||||||
└── rerun_grpc_url в REST/WebSocket state
|
|
||||||
└── встроенный @rerun-io/web-viewer
|
|
||||||
└── пространственная сцена NODE.DC
|
|
||||||
|
|
||||||
React Control Station ←→ REST /api/* + WebSocket /api/events
|
|
||||||
FastAPI на 127.0.0.1:8000
|
|
||||||
CoreBluetooth + live/replay runtime
|
|
||||||
```
|
|
||||||
|
|
||||||
В момент готовности `RerunBridge` backend публикует адрес вида
|
|
||||||
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
|
|
||||||
если оператор не указал ручной source. После остановки приёма URL и встроенный
|
|
||||||
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
|
|
||||||
траекторию и метрики и использует тот же listener. Он закрывается вместе с
|
|
||||||
процессом `k1link serve`.
|
|
||||||
|
|
||||||
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
|
|
||||||
совместимость со старым контрактом, но текущий runtime держит их пустыми.
|
|
||||||
|
|
||||||
## Фиксированная оболочка
|
|
||||||
|
|
||||||
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
|
|
||||||
всех функциональных модулей:
|
|
||||||
|
|
||||||
1. `AppHeader` — марка NODE.DC, выбор архитектурного раздела и состояние
|
|
||||||
локального backend.
|
|
||||||
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
|
|
||||||
выбранного раздела.
|
|
||||||
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
|
|
||||||
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
|
|
||||||
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
|
|
||||||
раскрытия внутренних панелей визуального движка.
|
|
||||||
|
|
||||||
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
|
|
||||||
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
|
|
||||||
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
|
|
||||||
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
|
|
||||||
Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение
|
|
||||||
layout пока остаются интерфейсным контрактом.
|
|
||||||
|
|
||||||
## Архитектурные разделы
|
|
||||||
|
|
||||||
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
|
|
||||||
страницы.
|
|
||||||
|
|
||||||
| Раздел | Назначение |
|
|
||||||
| --- | --- |
|
|
||||||
| Центр | Оперативный обзор, состояние контура и активность оператора. |
|
|
||||||
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
|
|
||||||
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
|
|
||||||
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
|
|
||||||
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
|
|
||||||
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
|
|
||||||
|
|
||||||
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
|
|
||||||
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
|
|
||||||
как утверждение, что для каждой карточки уже существует backend.
|
|
||||||
|
|
||||||
## Установка, проверка, сборка и запуск
|
|
||||||
|
|
||||||
Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
|
|
||||||
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
|
|
||||||
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
|
|
||||||
|
|
||||||
Канонический путь от корня репозитория:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
|
|
||||||
uv sync --group dev
|
|
||||||
|
|
||||||
cd apps/k1-viewer
|
cd apps/k1-viewer
|
||||||
npm install
|
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
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на
|
The development server listens on `http://127.0.0.1:5173` and proxies `/api` to
|
||||||
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
|
`http://127.0.0.1:8000`. Set `VITE_API_TARGET` in a local `.env` file to use a
|
||||||
`VITE_API_TARGET`. Preview production-сборки запускается командой
|
different backend address. Production artifacts are built with `npm run build`.
|
||||||
`npm run preview` на `http://127.0.0.1:4173`.
|
|
||||||
|
|
||||||
## Операторский путь для текущего 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.
|
## Backend contract
|
||||||
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.
|
|
||||||
|
|
||||||
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
|
| Method | Route | Body / purpose |
|
||||||
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.5–12.0`, режимы
|
|
||||||
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
|
|
||||||
Plasma, grayscale и custom, накопление `0–120` секунд, а также видимость облака,
|
|
||||||
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
|
|
||||||
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
|
|
||||||
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
|
|
||||||
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
|
|
||||||
а не готовую семантическую классификацию.
|
|
||||||
|
|
||||||
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
|
|
||||||
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
|
|
||||||
в памяти текущей страницы и не записывается на диск.
|
|
||||||
|
|
||||||
## Контракт локального API
|
|
||||||
|
|
||||||
| Метод | Route | Тело / назначение |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET` | `/api/health` | Проверка локального сервиса. |
|
| `GET` | `/api/health` | Local service health |
|
||||||
| `GET` | `/api/state` | Авторитетный snapshot состояния. |
|
| `GET` | `/api/state` | Authoritative console state |
|
||||||
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. |
|
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }` |
|
||||||
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. |
|
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }` |
|
||||||
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
|
| `POST` | `/api/session/live` | Optional `{ "host", "duration_seconds" }` |
|
||||||
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
|
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }` |
|
||||||
| `POST` | `/api/session/stop` | Остановка активного источника. |
|
| `POST` | `/api/session/stop` | Stop the active source |
|
||||||
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
|
| `WS` | `/api/events` | State snapshots for live UI updates |
|
||||||
| `WS` | `/api/events` | Периодические snapshots для live UI. |
|
|
||||||
|
|
||||||
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
|
`/api/state` and state-changing responses may return the snapshot directly or as
|
||||||
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
|
`{ "state": { ... } }`. A snapshot exposes `phase`, `message`, `devices`,
|
||||||
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
|
`selected_device_id`, `k1_ip`, `foxglove_ws_url`, `foxglove_viewer_url`,
|
||||||
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
|
`source_mode` and `metrics`.
|
||||||
пустыми. OpenAPI доступен по `/api/docs`.
|
|
||||||
|
|
||||||
## Карта исходников 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.
|
||||||
|
|
||||||
| Файл | Ответственность |
|
## Explicit unavailable states
|
||||||
| --- | --- |
|
|
||||||
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
|
|
||||||
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
|
|
||||||
| `src/workspaces/DeviceWorkspace.tsx` | Реальный K1 BLE/Wi-Fi/live/replay adapter UI. |
|
|
||||||
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
|
||||||
| `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
|
|
||||||
| `src/api.ts` | REST/WebSocket контракт с FastAPI. |
|
|
||||||
| `src/useK1Console.ts` | Состояние backend, polling, события и действия оператора. |
|
|
||||||
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
|
|
||||||
| `src/presentation.ts` | Русские подписи фаз и форматирование реальных метрик. |
|
|
||||||
| `src/messages.ts` | Обезличивание и локализация технических сообщений в пользовательском интерфейсе. |
|
|
||||||
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
|
|
||||||
|
|
||||||
## Safety и чувствительные данные
|
- 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.
|
||||||
- Control Station и credential endpoint доступны только на loopback.
|
- The Foxglove button is disabled until the backend supplies
|
||||||
- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
|
`foxglove_viewer_url`.
|
||||||
хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет
|
- Replay cannot start without a backend-local capture path.
|
||||||
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
|
|
||||||
и безопасная документация.
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Пункт управления NODE.DC — наблюдение, данные и планирование миссий беспилотных аппаратов."
|
content="Локальная консоль подключения XGRIDS Lixel K1 и просмотра облака точек в реальном времени."
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#08090b" />
|
<meta name="theme-color" content="#08090b" />
|
||||||
<title>NODE.DC · Пункт управления</title>
|
<title>K1 · Облако точек в реальном времени · NODE.DC</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"@rerun-io/web-viewer": "0.34.1",
|
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -21,8 +20,7 @@
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0"
|
||||||
"vite-plugin-wasm": "^3.6.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|
@ -846,12 +844,6 @@
|
||||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@rerun-io/web-viewer": {
|
|
||||||
"version": "0.34.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.34.1.tgz",
|
|
||||||
"integrity": "sha512-2Oq9Mw3qOs765XArGq4e/0pAIksPFKlAqkgo+PDsRkPd77/KdnQhb835suRmYQ6tuqlbPeVCFhlNIXT0+TjmTg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"@rerun-io/web-viewer": "0.34.1",
|
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -23,8 +22,7 @@
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0"
|
||||||
"vite-plugin-wasm": "^3.6.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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
|
|
@ -30,23 +30,10 @@ export interface ConsoleState {
|
||||||
k1_ip?: string | null;
|
k1_ip?: string | null;
|
||||||
foxglove_ws_url?: string | null;
|
foxglove_ws_url?: string | null;
|
||||||
foxglove_viewer_url?: string | null;
|
foxglove_viewer_url?: string | null;
|
||||||
rerun_grpc_url?: string | null;
|
|
||||||
viewer_settings?: ViewerSettings | null;
|
|
||||||
source_mode?: SourceMode | null;
|
source_mode?: SourceMode | null;
|
||||||
metrics?: K1Metrics;
|
metrics?: K1Metrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ViewerSettings {
|
|
||||||
point_size: number;
|
|
||||||
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
|
|
||||||
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
|
||||||
custom_color: string;
|
|
||||||
accumulation_seconds: number;
|
|
||||||
show_points: boolean;
|
|
||||||
show_trajectory: boolean;
|
|
||||||
show_grid: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthResponse {
|
export interface HealthResponse {
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
@ -112,7 +99,7 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
|
throw new ApiError("Не удалось подключиться к локальному API K1.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyText = await response.text();
|
const bodyText = await response.text();
|
||||||
|
|
@ -132,9 +119,9 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||||
? body.detail
|
? body.detail
|
||||||
: typeof body === "string" && body.trim()
|
: typeof body === "string" && body.trim()
|
||||||
? body.trim()
|
? body.trim()
|
||||||
: `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
|
: `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`;
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
|
detail || `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`,
|
||||||
response.status,
|
response.status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -187,10 +174,6 @@ export const api = {
|
||||||
stopSession(): Promise<ConsoleState> {
|
stopSession(): Promise<ConsoleState> {
|
||||||
return postState("/api/session/stop");
|
return postState("/api/session/stop");
|
||||||
},
|
},
|
||||||
|
|
||||||
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> {
|
|
||||||
return postState("/api/viewer/settings", body);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
|
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
|
||||||
|
|
@ -220,5 +203,5 @@ export function openEventSocket(
|
||||||
socket.addEventListener("error", () => onStatus("error"));
|
socket.addEventListener("error", () => onStatus("error"));
|
||||||
socket.addEventListener("close", () => onStatus("closed"));
|
socket.addEventListener("close", () => onStatus("closed"));
|
||||||
|
|
||||||
return () => socket.close(1000, "Пункт управления закрыт");
|
return () => socket.close(1000, "Консоль K1 закрыта");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,7 @@ import "./styles.css";
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("Не найден корневой элемент пункта управления.");
|
throw new Error("K1 console root element is missing.");
|
||||||
}
|
}
|
||||||
|
|
||||||
rootElement.classList.add("nodedc-ui-root");
|
rootElement.classList.add("nodedc-ui-root");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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: "Передача настроек Wi‑Fi",
|
|
||||||
connecting: "Подключение",
|
|
||||||
connected: "Устройство подключено",
|
|
||||||
starting_live: "Запуск потока",
|
|
||||||
live: "Поток в реальном времени",
|
|
||||||
replay: "Повтор записи",
|
|
||||||
stopping: "Остановка",
|
|
||||||
error: "Ошибка",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function phaseLabel(phase: string | null | undefined): string {
|
|
||||||
if (!phase) return "Нет состояния";
|
|
||||||
return phaseLabels[phase] ?? "Неизвестное состояние";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function phaseTone(phase: string | null | undefined): StatusTone {
|
|
||||||
if (!phase) return "neutral";
|
|
||||||
if (phase === "error") return "danger";
|
|
||||||
if (["connected", "live", "replay"].includes(phase)) return "success";
|
|
||||||
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) {
|
|
||||||
return "accent";
|
|
||||||
}
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function backendLabel(status: BackendStatus): string {
|
|
||||||
return {
|
|
||||||
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 "Неизвестно";
|
|
||||||
}
|
|
||||||
|
|
@ -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 → Wi‑Fi → поток для первого подключённого устройства.",
|
|
||||||
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: "Следующий этап",
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +1,970 @@
|
||||||
@import "./styles/base.css";
|
:root {
|
||||||
@import "./styles/shell.css";
|
background: #050506;
|
||||||
@import "./styles/workspaces.css";
|
}
|
||||||
@import "./styles/spatial.css";
|
|
||||||
@import "./styles/device.css";
|
html {
|
||||||
@import "./styles/responsive.css";
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -9,25 +9,21 @@ import {
|
||||||
type EventSocketStatus,
|
type EventSocketStatus,
|
||||||
type LiveRequest,
|
type LiveRequest,
|
||||||
type ReplayRequest,
|
type ReplayRequest,
|
||||||
type ViewerSettings,
|
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import { localizeRuntimeMessage } from "./messages";
|
|
||||||
|
|
||||||
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
||||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
|
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop";
|
||||||
|
|
||||||
function messageFor(error: unknown): string {
|
function messageFor(error: unknown): string {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
const message = localizeRuntimeMessage(error.message) ?? error.message;
|
|
||||||
return error.status
|
return error.status
|
||||||
? `${message} (HTTP ${error.status})`
|
? `${error.message} (HTTP ${error.status})`
|
||||||
: message;
|
: error.message;
|
||||||
}
|
}
|
||||||
return "Запрос к локальному сервису устройства завершился ошибкой.";
|
return "Запрос к локальному API K1 завершился ошибкой.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function measuredLatency(state: ConsoleState | null): number | null {
|
function measuredLatency(state: ConsoleState | null): number | null {
|
||||||
if (state?.source_mode !== "live") return null;
|
|
||||||
const metrics = state?.metrics;
|
const metrics = state?.metrics;
|
||||||
if (!metrics) return null;
|
if (!metrics) return null;
|
||||||
|
|
||||||
|
|
@ -36,10 +32,11 @@ function measuredLatency(state: ConsoleState | null): number | null {
|
||||||
|
|
||||||
const segments = [
|
const segments = [
|
||||||
metrics.mqtt_to_decode_ms,
|
metrics.mqtt_to_decode_ms,
|
||||||
|
metrics.decode_ms,
|
||||||
metrics.publish_ms,
|
metrics.publish_ms,
|
||||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||||
|
|
||||||
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
|
return segments.length ? segments.reduce((total, value) => total + value, 0) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useK1Console() {
|
export function useK1Console() {
|
||||||
|
|
@ -131,11 +128,6 @@ export function useK1Console() {
|
||||||
[run],
|
[run],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateViewerSettings = useCallback(
|
|
||||||
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
|
|
||||||
[run],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mounted.current = true;
|
mounted.current = true;
|
||||||
void refresh(true);
|
void refresh(true);
|
||||||
|
|
@ -177,10 +169,7 @@ export function useK1Console() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const latency = measuredLatency(state);
|
const latency = measuredLatency(state);
|
||||||
if (latency === null) {
|
if (latency === null) return;
|
||||||
setLatencyHistory((values) => (values.length ? [] : values));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLatencyHistory((values) => [...values.slice(-23), latency]);
|
setLatencyHistory((values) => [...values.slice(-23), latency]);
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
|
|
@ -198,6 +187,5 @@ export function useK1Console() {
|
||||||
startLive,
|
startLive,
|
||||||
startReplay,
|
startReplay,
|
||||||
stop,
|
stop,
|
||||||
updateViewerSettings,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
|
||||||
<h2>Подключите устройство к сети</h2>
|
|
||||||
</div>
|
|
||||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="wizard-list">
|
|
||||||
<WizardStep
|
|
||||||
number="01"
|
|
||||||
title="Включите устройство"
|
|
||||||
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
|
|
||||||
tone={powerConfirmed ? "success" : "warning"}
|
|
||||||
>
|
|
||||||
<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="Передайте настройки Wi‑Fi"
|
|
||||||
status={state?.k1_ip ? "Подключено" : "Не подключено"}
|
|
||||||
tone={state?.k1_ip ? "success" : "neutral"}
|
|
||||||
>
|
|
||||||
<div className="field-stack">
|
|
||||||
<TextField
|
|
||||||
label="Название сети Wi‑Fi"
|
|
||||||
hint="SSID"
|
|
||||||
value={ssid}
|
|
||||||
onChange={(event) => setSsid(event.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="Сеть локального контура"
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Пароль Wi‑Fi"
|
|
||||||
hint="Только в оперативной памяти"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Введите пароль"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="connection-summary">
|
|
||||||
<span>Устройство</span>
|
|
||||||
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
width="full"
|
|
||||||
variant="primary"
|
|
||||||
icon={<Icon name="network" />}
|
|
||||||
disabled={!canConnect}
|
|
||||||
onClick={() => void submitConnect()}
|
|
||||||
>
|
|
||||||
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
|
||||||
</Button>
|
|
||||||
<p className="safety-note">
|
|
||||||
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
|
|
||||||
удаляется из формы после успешного подключения.
|
|
||||||
</p>
|
|
||||||
</WizardStep>
|
|
||||||
</div>
|
|
||||||
</GlassSurface>
|
|
||||||
|
|
||||||
<div className="device-workspace__side">
|
|
||||||
<GlassSurface className="session-panel" padding="lg">
|
|
||||||
<header className="panel-heading">
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">
|
|
||||||
{sessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
|
||||||
</span>
|
|
||||||
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
|
|
||||||
</div>
|
|
||||||
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
|
|
||||||
{sourceLabel}
|
|
||||||
</StatusBadge>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<SegmentedControl
|
|
||||||
label="Источник данных"
|
|
||||||
value={sessionIntent}
|
|
||||||
items={sessionItems}
|
|
||||||
onChange={setSessionIntent}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{sessionIntent === "live" ? (
|
|
||||||
<div className="session-form">
|
|
||||||
<TextField
|
|
||||||
label="Адрес устройства"
|
|
||||||
hint="Обычно определяется автоматически"
|
|
||||||
value={liveHost}
|
|
||||||
onChange={(event) => setLiveHost(event.target.value)}
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
icon={<Icon name="activity" />}
|
|
||||||
disabled={isBusy || !liveTargetReady}
|
|
||||||
onClick={() => void submitLive()}
|
|
||||||
>
|
|
||||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
|
||||||
</Button>
|
|
||||||
<p className="live-instruction">
|
|
||||||
{liveTargetReady
|
|
||||||
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
|
|
||||||
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="session-form session-form--replay">
|
|
||||||
<TextField
|
|
||||||
label="Путь к записи"
|
|
||||||
hint="Локальный файл исходных данных"
|
|
||||||
value={replayPath}
|
|
||||||
onChange={(event) => setReplayPath(event.target.value)}
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="sessions/.../capture.tsv"
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Скорость повтора"
|
|
||||||
hint="Множитель"
|
|
||||||
type="number"
|
|
||||||
min="0.1"
|
|
||||||
step="0.1"
|
|
||||||
value={replaySpeed}
|
|
||||||
onChange={(event) => setReplaySpeed(event.target.value)}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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, Wi‑Fi и запуск потока</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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import wasm from "vite-plugin-wasm";
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), "");
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react(), wasm()],
|
plugins: [react()],
|
||||||
build: {
|
|
||||||
target: "esnext",
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|
|
||||||
|
|
@ -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
|
Status: alpha implementation for the verified firmware-3 MQTT streams. It uses
|
||||||
MQTT streams. Live capture and replay use real K1 point-cloud and pose messages;
|
real K1 data and does not generate a placeholder cloud, pose or latency value.
|
||||||
the application does not generate a placeholder cloud, trajectory, camera frame
|
|
||||||
or latency value.
|
|
||||||
|
|
||||||
## Active device-to-scene path
|
## Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
K1 lio_pcl / lio_pose
|
K1 lio_pcl / lio_pose
|
||||||
|
|
@ -13,263 +11,139 @@ K1 lio_pcl / lio_pose
|
||||||
v
|
v
|
||||||
read-only MQTT subscription on TCP 1883
|
read-only MQTT subscription on TCP 1883
|
||||||
|
|
|
|
||||||
+--> raw .k1mqtt + JSONL + SHA-256 summary (written first)
|
+--> raw .k1mqtt + JSONL + SHA-256 summary (first)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
bounded latest-wins preview queue (32 messages)
|
bounded latest-wins preview queue (32 messages)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
reviewed raw-LZ4/protobuf decoders
|
raw-LZ4/protobuf decoder --> foxglove.PointCloud / Pose / SceneUpdate
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Rerun Points3D + Transform3D + LineStrips3D
|
ws://127.0.0.1:8765 --> Foxglove 3D
|
||||||
|
|
|
||||||
v
|
|
||||||
Rerun gRPC/proxy on TCP 9876
|
|
||||||
|
|
|
||||||
+--> rerun_grpc_url in REST/WebSocket state
|
|
||||||
|
|
|
||||||
v
|
|
||||||
self-hosted @rerun-io/web-viewer inside NODE.DC Control Station
|
|
||||||
|
|
||||||
React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
|
React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
The Paho MQTT callback does not decode or render the point cloud. It first
|
The Paho MQTT callback never decodes the point cloud. It writes and flushes the
|
||||||
writes and flushes the raw frame and metadata, then enqueues a preview message.
|
raw frame and metadata, then enqueues a preview reference. If visualization cannot
|
||||||
If visualization cannot keep up, the oldest queued preview is discarded while
|
keep up, the oldest queued preview is discarded while the raw capture continues.
|
||||||
the raw capture continues. Rerun work stays on the dedicated publisher thread
|
|
||||||
and cannot block raw-first evidence capture.
|
|
||||||
|
|
||||||
The current live/replay runtime instantiates only `RerunBridge`. The former
|
|
||||||
Foxglove implementation is not a parallel runtime and does not listen on TCP
|
|
||||||
8765; it is retained only as a legacy regression module and test oracle.
|
|
||||||
|
|
||||||
## Start the local application
|
## Start the local application
|
||||||
|
|
||||||
Prerequisites are the repository-local Python environment and Node.js 20.19+ or
|
Prerequisites are the repository-local Python environment and Node.js 20 or
|
||||||
22.12+. No global Python package or system component is installed.
|
newer. No global Python package or system component is installed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --group dev
|
uv sync --group dev
|
||||||
cd apps/k1-viewer
|
cd apps/k1-viewer
|
||||||
npm install
|
npm install
|
||||||
npm run typecheck
|
|
||||||
npm run build
|
npm run build
|
||||||
cd ../..
|
cd ../..
|
||||||
uv run k1link serve
|
uv run k1link serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://127.0.0.1:8000`. The static Control Station, REST API, WebSocket
|
Open `http://127.0.0.1:8000`. `k1link serve` intentionally exposes no LAN bind
|
||||||
state channel and credential endpoint bind to loopback. The Rerun gRPC server is
|
option because its provisioning endpoint temporarily receives a Wi-Fi password.
|
||||||
a separate listener with a different network boundary described under
|
The password is accepted only in the POST body, is never logged or persisted by
|
||||||
[Network and security boundary](#network-and-security-boundary).
|
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.
|
1. In **Session**, select **Replay capture**.
|
||||||
2. Enter a native `mqtt.raw.k1mqtt` capture or a reviewed four-column TSV,
|
2. Enter the repository-relative path:
|
||||||
for example:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
|
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Select replay speed and optional looping, then start the session.
|
3. Use speed `1` and enable loop for initial viewer setup.
|
||||||
4. The runtime starts the process-wide Rerun `RecordingStream` and its
|
4. Start replay, then select **Open Foxglove 3D**.
|
||||||
gRPC/proxy server on TCP 9876 before the first replay. Later sessions reuse it.
|
5. In a Foxglove 3D panel, enable `/k1/points`, `/k1/pose` and
|
||||||
5. Open **Наблюдение → Пространственная сцена**. With the manual source field
|
`/k1/trajectory`.
|
||||||
empty, the embedded viewer automatically uses:
|
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
|
The reviewed TSV reader accepts exactly four columns: receive timestamp, topic,
|
||||||
rerun+http://127.0.0.1:9876/proxy
|
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
|
||||||
6. Use **Отображение** and **Слои** to adjust the real cloud and trajectory.
|
timestamps when present.
|
||||||
7. Stop the session when finished. Acquisition and session metrics stop, while
|
|
||||||
the embedded viewer and `rerun_grpc_url` remain ready. A later session resets
|
|
||||||
scene-local state and reuses the same listener without reloading the page.
|
|
||||||
|
|
||||||
The TSV reader accepts exactly receive timestamp, topic, declared payload length
|
|
||||||
and hexadecimal payload. It verifies timestamp, UTF-8 topic, declared length,
|
|
||||||
hex encoding and allocation bounds. Native `.k1mqtt` captures are supported
|
|
||||||
directly and use their sibling metadata receive timestamps when present.
|
|
||||||
|
|
||||||
## Connect and stream live
|
## Connect and stream live
|
||||||
|
|
||||||
1. Power K1 to its normal steady-green standby state.
|
1. Power K1 to its normal steady-green standby state.
|
||||||
2. Confirm the manual power checklist in **Парк → Локальное устройство**.
|
2. Confirm the manual power checklist in **Connect**.
|
||||||
3. Run the real six-second BLE scan and select the intended device from the
|
3. Run the real six-second BLE scan and select the K1 candidate.
|
||||||
complete visible-device list.
|
4. Enter the existing router SSID/password and press **Provision Wi-Fi &
|
||||||
4. Enter the existing router SSID/password and explicitly authorize the reviewed
|
connect**. That button is the explicit authorization for one reviewed 99-byte
|
||||||
provisioning write. The backend does not retry the write automatically.
|
provisioning write; the backend never retries automatically.
|
||||||
5. When K1 reports a non-AP private address, start live reception.
|
5. When K1 reports a non-AP private address, press **Start live stream**.
|
||||||
6. Wait until the UI reports that the local Rerun bridge is ready. No manual
|
6. Open Foxglove before scanning if convenient.
|
||||||
viewer URL is needed.
|
7. Double-click the physical K1 button to start scanning. Double-click again to
|
||||||
7. Open **Наблюдение → Пространственная сцена**.
|
stop, wait for the LED to return to steady green, then stop the local session.
|
||||||
8. Double-click the physical K1 button to start scanning. Real point frames and
|
|
||||||
pose/trajectory updates then appear in the embedded viewport.
|
|
||||||
9. Double-click K1 again to stop physical scanning, wait for steady green, then
|
|
||||||
stop the local session so captures and summaries are finalized.
|
|
||||||
|
|
||||||
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a
|
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a
|
||||||
redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
|
redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
|
||||||
hash summary. The connector subscribes to the fixed report-topic allowlist and
|
hash summary. No application request topic is published.
|
||||||
does not publish an application request or modeling command.
|
|
||||||
|
|
||||||
## Automatic Rerun source and lifecycle
|
## Published topics
|
||||||
|
|
||||||
On the first live or replay session, `RerunBridge`:
|
| Topic | Foxglove schema | Meaning |
|
||||||
|
|
||||||
- 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 |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `/world` | `ViewCoordinates` | right-handed Z-up display convention |
|
| `/k1/points` | `foxglove.PointCloud` | metric XYZ float32 + uint8 intensity, stride 16 |
|
||||||
| `/world/points` | `Points3D` | decoded metric XYZ with computed or available RGB colors |
|
| `/k1/pose` | `foxglove.PoseInFrame` | current decoded K1 pose in the `map` frame |
|
||||||
| `/world/sensor_pose` | `Transform3D` + `TransformAxes3D` | current decoded translation, xyzw quaternion and pose axes |
|
| `/k1/trajectory` | `foxglove.SceneUpdate` | bounded cyan line strip, throttled while growing |
|
||||||
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 20,000 decoded poses |
|
| `/k1/metrics` | JSON schema | latency, decode/publish time, FPS, points and drops |
|
||||||
|
|
||||||
Point count, frame rate, queue drops and measured pipeline time remain product
|
Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as verified.
|
||||||
metrics in the outer Control Station; they are not logged as Rerun entities and
|
No axis swap, quaternion normalization or vehicle extrinsic is silently applied.
|
||||||
therefore cannot create additional automatic viewer panes.
|
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.
|
## Latency semantics
|
||||||
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.
|
|
||||||
|
|
||||||
Only the low byte of firmware-3 `rgbi` has been verified as intensity. The
|
`pipeline_ms` / `mqtt_to_publish_ms` is measured with the host monotonic clock
|
||||||
upper bytes are not labeled RGB. Legacy frames use RGB only when those decoded
|
from MQTT callback receipt through raw disk write, bounded queue wait, decode,
|
||||||
fields are actually present.
|
packing and Foxglove channel publish. Rolling p50/p95 values use the last 512
|
||||||
|
published decoded messages.
|
||||||
|
|
||||||
## Scene controls
|
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
|
||||||
The current product controls call `POST /api/viewer/settings`; backend state is
|
outside the Python publisher, and display latency is not observable without a
|
||||||
authoritative and the bridge applies the updated settings to subsequent
|
clock-correlated device timestamp or high-speed-camera experiment. Foxglove
|
||||||
messages.
|
message time therefore uses host receive Unix time while durations use
|
||||||
|
`monotonic_ns`.
|
||||||
| Control | Implemented behavior |
|
|
||||||
| --- | --- |
|
|
||||||
| Point size | Rerun UI-point radius from 0.5 to 12.0; default 2.5 |
|
|
||||||
| Color attribute | intensity, height Z, distance from the point-coordinate origin, available RGB, or solid class/custom color |
|
|
||||||
| Palette | Turbo, Viridis, Plasma, grayscale or operator-selected custom color |
|
|
||||||
| Accumulation | sliding session-local `stream_time` window from 0 to 120 seconds; default 12 seconds |
|
|
||||||
| Point layer | shows or clears `/world/points` |
|
|
||||||
| Trajectory | shows or clears the decoded path; publication is throttled while it grows |
|
|
||||||
| Grid | updates visibility of the Rerun 3D line grid through the active blueprint |
|
|
||||||
|
|
||||||
The default 12-second accumulation is a viewer time range over real point
|
|
||||||
frames; it does not duplicate or alter the raw capture. An accumulation of zero
|
|
||||||
shows only the current frame. RGB mode falls back to the verified scalar
|
|
||||||
coloring path when the active point format has no RGB fields. The current
|
|
||||||
`class`/custom behavior is a selected solid color, not semantic segmentation.
|
|
||||||
|
|
||||||
Projection switching, a custom playback timeline, semantic object/mask layers,
|
|
||||||
camera frustums and persisted RBL/layout profiles are not wired yet and must not
|
|
||||||
be inferred from the implemented controls above.
|
|
||||||
|
|
||||||
## Time and latency semantics
|
|
||||||
|
|
||||||
The Rerun `capture_time` timeline uses
|
|
||||||
`StreamMessage.received_at_epoch_ns`: Unix time when the Mac received the MQTT
|
|
||||||
message. The K1 header timestamp epoch is not proven, so `capture_time` is not
|
|
||||||
a sensor acquisition timestamp. The viewer accumulation window uses
|
|
||||||
`stream_time`, assigned when the current live/replay run publishes a message;
|
|
||||||
therefore replayed historical timestamps do not mix two sequential sessions.
|
|
||||||
|
|
||||||
For live MQTT only, `pipeline_ms` / `mqtt_to_publish_ms` uses the Mac monotonic
|
|
||||||
clock from MQTT callback receipt through raw disk write, bounded queue wait,
|
|
||||||
decode, Rerun packing and the SDK log call. Replay and idle states expose no
|
|
||||||
latency value. Browser fetch, WebAssembly ingestion, GPU rendering and display
|
|
||||||
scanout are outside this measurement.
|
|
||||||
|
|
||||||
The displayed value is therefore Mac pipeline latency, not sensor-photon-to-
|
|
||||||
screen latency. End-to-end display latency requires a proven device clock or a
|
|
||||||
separate clock-correlated/high-speed-camera experiment.
|
|
||||||
|
|
||||||
## Network and security boundary
|
|
||||||
|
|
||||||
There are two different listeners:
|
|
||||||
|
|
||||||
- FastAPI, the WebSocket control plane and Wi-Fi credential endpoint bind only
|
|
||||||
to `127.0.0.1:8000`;
|
|
||||||
- Rerun gRPC/proxy on TCP 9876 currently binds **all network interfaces**, even
|
|
||||||
though the URL returned to the local browser contains `127.0.0.1`.
|
|
||||||
|
|
||||||
The Rerun endpoint has no connector-level authentication and no TLS. It may
|
|
||||||
expose point clouds, trajectory and mapped interiors to any host that can reach
|
|
||||||
the port. Use it only on a trusted laboratory LAN, block TCP 9876 at the router
|
|
||||||
and host firewall as appropriate, and never forward it directly to the public
|
|
||||||
Internet or cellular WAN. Remote operation requires an authenticated TLS reverse
|
|
||||||
proxy or another reviewed secure transport before deployment.
|
|
||||||
|
|
||||||
The 512 MiB Rerun server buffer limits retained late-client data but is not an
|
|
||||||
access-control mechanism. The listener and embedded viewer intentionally remain
|
|
||||||
ready between acquisition sessions. Stop `k1link serve` when the network
|
|
||||||
listener and its process memory must be closed unconditionally.
|
|
||||||
|
|
||||||
## Current boundaries
|
## Current boundaries
|
||||||
|
|
||||||
- Raw panoramic camera frames were absent from the observed MQTT report topics.
|
- Raw panoramic camera frames were not present on the observed MQTT report
|
||||||
This milestone contains point cloud and pose/trajectory; operational metrics
|
topics; this milestone intentionally ships point cloud plus trajectory only.
|
||||||
are rendered by the outer Control Station.
|
- Physical double-click remains the start/stop control. A future MQTT modeling
|
||||||
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
|
||||||
publisher needs a separately reviewed state-changing profile.
|
publisher needs a separately reviewed state-changing profile.
|
||||||
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
- The React application opens the official Foxglove viewer over a local
|
||||||
mission planner or vehicle control is implemented by this viewer milestone.
|
WebSocket. It does not embed or fork the proprietary modern viewer; Foxglove
|
||||||
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
|
account/seat terms apply to that viewer independently of this connector.
|
||||||
calibration task.
|
- Exact path axes and a scanner-to-vehicle transform must be calibrated before
|
||||||
- A single local runtime owns TCP 9876. A port collision causes a visible bridge
|
mounting on the unmanned platform.
|
||||||
startup error rather than silently selecting another source. The Rerun native
|
|
||||||
listener lives for the lifetime of `k1link serve` and is reused by later
|
|
||||||
sessions in that process; shutdown closes it explicitly.
|
|
||||||
|
|
||||||
## Verification checkpoint — 2026-07-16
|
## Verification checkpoint — 2026-07-15
|
||||||
|
|
||||||
A powered K1 capture was passed through the active MQTT → decoder → Rerun gRPC →
|
The full captured 180-second scan was passed through the production Foxglove
|
||||||
embedded Web Viewer path without a viewer-side substitute:
|
bridge without a viewer-side substitute:
|
||||||
|
|
||||||
| Check | Result |
|
| Check | Result |
|
||||||
| --- | ---: |
|
| --- | ---: |
|
||||||
| real MQTT messages processed | 80 |
|
| MQTT messages read | 2,836 |
|
||||||
| `lio_pcl` frames published | 38 |
|
| `lio_pcl` frames published | 1,140 |
|
||||||
| `lio_pose` frames published | 42 |
|
| `lio_pose` frames published | 1,215 |
|
||||||
| points in the last cloud | 2,775 |
|
| points packed and published | 4,165,862 |
|
||||||
| decoder errors | 0 |
|
| decoder errors | 0 |
|
||||||
|
| final trajectory poses | 1,215 |
|
||||||
|
|
||||||
The 38 point frames and 42 pose frames account for all 80 observed messages.
|
The same replay was then started through the FastAPI/React contract at `10x`.
|
||||||
This proves current real-device decoding, bridge publication and embedded-viewer
|
The API exposed the loopback Foxglove URL, live point/pose metrics and non-zero
|
||||||
delivery for point cloud plus trajectory. It does not prove a camera channel or
|
preview-drop accounting under deliberate acceleration, and released TCP 8765
|
||||||
sensor-to-screen latency.
|
after the stop request. This validates replay, overload behavior and lifecycle;
|
||||||
|
the next checkpoint is the powered K1 ideal-LAN live run.
|
||||||
## Legacy Foxglove regression module
|
|
||||||
|
|
||||||
`src/k1link/viewer/foxglove_bridge.py` and its tests remain in the repository
|
|
||||||
to compare decoder/packing behavior and preserve earlier evidence. They are not
|
|
||||||
instantiated by `VisualizationRuntime`, are not the Control Station source, and
|
|
||||||
do not make TCP 8765 part of the current operator path.
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ dependencies = [
|
||||||
"foxglove-sdk==0.25.3",
|
"foxglove-sdk==0.25.3",
|
||||||
"lz4>=4.4,<5",
|
"lz4>=4.4,<5",
|
||||||
"paho-mqtt>=2.1,<3",
|
"paho-mqtt>=2.1,<3",
|
||||||
"rerun-sdk==0.34.1",
|
|
||||||
"rich>=13.9,<15",
|
"rich>=13.9,<15",
|
||||||
"typer>=0.15,<1",
|
"typer>=0.15,<1",
|
||||||
"uvicorn[standard]>=0.35,<1",
|
"uvicorn[standard]>=0.35,<1",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -11,10 +11,9 @@ from typing import Literal, TypedDict
|
||||||
|
|
||||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||||
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
||||||
from k1link.viewer.foxglove_bridge import BridgeMetrics, MetricsSnapshot
|
from k1link.viewer.foxglove_bridge import BridgeMetrics, FoxgloveBridge, MetricsSnapshot
|
||||||
from k1link.viewer.messages import StreamMessage
|
from k1link.viewer.messages import StreamMessage
|
||||||
from k1link.viewer.replay import iter_replay_messages
|
from k1link.viewer.replay import iter_replay_messages
|
||||||
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
|
|
||||||
|
|
||||||
RuntimePhase = Literal[
|
RuntimePhase = Literal[
|
||||||
"idle",
|
"idle",
|
||||||
|
|
@ -26,7 +25,6 @@ RuntimePhase = Literal[
|
||||||
]
|
]
|
||||||
SourceMode = Literal["idle", "live", "replay"]
|
SourceMode = Literal["idle", "live", "replay"]
|
||||||
StateCallback = Callable[[], None]
|
StateCallback = Callable[[], None]
|
||||||
BridgeFactory = Callable[..., RerunBridge]
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeSnapshot(TypedDict):
|
class RuntimeSnapshot(TypedDict):
|
||||||
|
|
@ -35,36 +33,22 @@ class RuntimeSnapshot(TypedDict):
|
||||||
source_mode: SourceMode
|
source_mode: SourceMode
|
||||||
foxglove_ws_url: str | None
|
foxglove_ws_url: str | None
|
||||||
foxglove_viewer_url: str | None
|
foxglove_viewer_url: str | None
|
||||||
rerun_grpc_url: str | None
|
|
||||||
viewer_settings: dict[str, object]
|
|
||||||
metrics: MetricsSnapshot
|
metrics: MetricsSnapshot
|
||||||
|
|
||||||
|
|
||||||
class VisualizationRuntime:
|
class VisualizationRuntime:
|
||||||
"""Own one bounded live/replay source and one deterministic publisher thread."""
|
"""Own one bounded live/replay source and one deterministic publisher thread."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, *, on_state_change: StateCallback | None = None) -> None:
|
||||||
self,
|
|
||||||
*,
|
|
||||||
on_state_change: StateCallback | None = None,
|
|
||||||
grpc_port: int = DEFAULT_GRPC_PORT,
|
|
||||||
bridge_factory: BridgeFactory | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._on_state_change = on_state_change
|
self._on_state_change = on_state_change
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self._phase: RuntimePhase = "idle"
|
self._phase: RuntimePhase = "idle"
|
||||||
self._message = "Готово. Включите устройство и начните с поиска по Bluetooth."
|
self._message = "Готово. Включите K1 и начните с поиска по Bluetooth."
|
||||||
self._source_mode: SourceMode = "idle"
|
self._source_mode: SourceMode = "idle"
|
||||||
self._foxglove_ws_url: str | None = None
|
self._foxglove_ws_url: str | None = None
|
||||||
self._foxglove_viewer_url: str | None = None
|
self._foxglove_viewer_url: str | None = None
|
||||||
self._rerun_grpc_url: str | None = None
|
|
||||||
self._grpc_port = grpc_port
|
|
||||||
self._bridge_factory = bridge_factory or RerunBridge
|
|
||||||
self._bridge: RerunBridge | None = None
|
|
||||||
self._closed = False
|
|
||||||
self._scene_settings = RerunSceneSettings()
|
|
||||||
self._metrics = BridgeMetrics()
|
self._metrics = BridgeMetrics()
|
||||||
|
|
||||||
def snapshot(self) -> RuntimeSnapshot:
|
def snapshot(self) -> RuntimeSnapshot:
|
||||||
|
|
@ -75,21 +59,13 @@ class VisualizationRuntime:
|
||||||
"source_mode": self._source_mode,
|
"source_mode": self._source_mode,
|
||||||
"foxglove_ws_url": self._foxglove_ws_url,
|
"foxglove_ws_url": self._foxglove_ws_url,
|
||||||
"foxglove_viewer_url": self._foxglove_viewer_url,
|
"foxglove_viewer_url": self._foxglove_viewer_url,
|
||||||
"rerun_grpc_url": self._rerun_grpc_url,
|
|
||||||
"viewer_settings": self._scene_settings.as_dict(),
|
|
||||||
"metrics": self._metrics.snapshot(),
|
"metrics": self._metrics.snapshot(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def update_scene_settings(self, settings: RerunSceneSettings) -> RuntimeSnapshot:
|
|
||||||
with self._lock:
|
|
||||||
self._scene_settings = settings
|
|
||||||
self._notify()
|
|
||||||
return self.snapshot()
|
|
||||||
|
|
||||||
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
|
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
|
||||||
resolved = path.expanduser().resolve()
|
resolved = path.expanduser().resolve()
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
raise ValueError("файл записи не найден на этом компьютере")
|
raise ValueError("файл записи не найден на этом MacBook")
|
||||||
if not math.isfinite(speed) or speed < 0:
|
if not math.isfinite(speed) or speed < 0:
|
||||||
raise ValueError("скорость повтора должна быть неотрицательным числом")
|
raise ValueError("скорость повтора должна быть неотрицательным числом")
|
||||||
# Validate the reviewed shape before changing runtime state.
|
# Validate the reviewed shape before changing runtime state.
|
||||||
|
|
@ -120,7 +96,7 @@ class VisualizationRuntime:
|
||||||
self._start(
|
self._start(
|
||||||
source_mode="live",
|
source_mode="live",
|
||||||
phase="starting_live",
|
phase="starting_live",
|
||||||
message="Запускаем приём MQTT и локальный мост визуализации.",
|
message="Запускаем приём MQTT и мост Foxglove.",
|
||||||
target=lambda: self._run_live(
|
target=lambda: self._run_live(
|
||||||
host,
|
host,
|
||||||
out_dir.expanduser().resolve(),
|
out_dir.expanduser().resolve(),
|
||||||
|
|
@ -147,34 +123,6 @@ class VisualizationRuntime:
|
||||||
assert thread is not None
|
assert thread is not None
|
||||||
thread.join(timeout=wait_seconds)
|
thread.join(timeout=wait_seconds)
|
||||||
|
|
||||||
def close(self, *, wait_seconds: float = 5.0) -> None:
|
|
||||||
"""Stop the active source and release the process-wide visual bridge."""
|
|
||||||
with self._lock:
|
|
||||||
self._closed = True
|
|
||||||
thread = self._thread
|
|
||||||
if thread is not None and thread.is_alive():
|
|
||||||
self._phase = "stopping"
|
|
||||||
self._message = "Завершаем локальный поток и визуальный мост."
|
|
||||||
self._stop_event.set()
|
|
||||||
else:
|
|
||||||
self._phase = "idle"
|
|
||||||
self._source_mode = "idle"
|
|
||||||
self._message = "Локальный поток завершён."
|
|
||||||
self._notify()
|
|
||||||
|
|
||||||
if thread is not None and thread.is_alive():
|
|
||||||
thread.join(timeout=wait_seconds)
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
thread_alive = self._thread is not None and self._thread.is_alive()
|
|
||||||
bridge = None if thread_alive else self._bridge
|
|
||||||
if not thread_alive:
|
|
||||||
self._bridge = None
|
|
||||||
self._rerun_grpc_url = None
|
|
||||||
if bridge is not None:
|
|
||||||
bridge.close()
|
|
||||||
self._notify()
|
|
||||||
|
|
||||||
def _start(
|
def _start(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -184,8 +132,6 @@ class VisualizationRuntime:
|
||||||
target: Callable[[], None],
|
target: Callable[[], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._closed:
|
|
||||||
raise RuntimeError("runtime завершён; перезапустите локальный сервер")
|
|
||||||
if self._thread is not None and self._thread.is_alive():
|
if self._thread is not None and self._thread.is_alive():
|
||||||
raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
|
raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
|
|
@ -253,8 +199,7 @@ class VisualizationRuntime:
|
||||||
out_dir / "captures" / "mqtt_live",
|
out_dir / "captures" / "mqtt_live",
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
on_ready=lambda: self._set_running(
|
on_ready=lambda: self._set_running(
|
||||||
"live",
|
"live", "Приём запущен. Теперь дважды нажмите физическую кнопку K1."
|
||||||
"Приём запущен. Теперь дважды нажмите физическую кнопку устройства.",
|
|
||||||
),
|
),
|
||||||
on_message_recorded=on_message,
|
on_message_recorded=on_message,
|
||||||
should_stop=self._stop_event.is_set,
|
should_stop=self._stop_event.is_set,
|
||||||
|
|
@ -275,7 +220,6 @@ class VisualizationRuntime:
|
||||||
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
|
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
|
||||||
source_done = threading.Event()
|
source_done = threading.Event()
|
||||||
publisher_ready = threading.Event()
|
publisher_ready = threading.Event()
|
||||||
publisher_aborted = threading.Event()
|
|
||||||
publisher_error: list[BaseException] = []
|
publisher_error: list[BaseException] = []
|
||||||
|
|
||||||
def enqueue(message: StreamMessage) -> None:
|
def enqueue(message: StreamMessage) -> None:
|
||||||
|
|
@ -296,41 +240,17 @@ class VisualizationRuntime:
|
||||||
self._metrics.preview_dropped()
|
self._metrics.preview_dropped()
|
||||||
|
|
||||||
def publish() -> None:
|
def publish() -> None:
|
||||||
bridge: RerunBridge | None = None
|
bridge: FoxgloveBridge | None = None
|
||||||
try:
|
try:
|
||||||
bridge = self._bridge
|
bridge = FoxgloveBridge(metrics=self._metrics)
|
||||||
if bridge is None:
|
|
||||||
candidate = self._bridge_factory(
|
|
||||||
grpc_port=self._grpc_port,
|
|
||||||
metrics=self._metrics,
|
|
||||||
settings_provider=self._current_scene_settings,
|
|
||||||
)
|
|
||||||
with self._lock:
|
|
||||||
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:
|
with self._lock:
|
||||||
if self._closed:
|
self._foxglove_ws_url = bridge.websocket_url
|
||||||
publisher_aborted.set()
|
self._foxglove_viewer_url = bridge.viewer_url
|
||||||
else:
|
if self._phase != "stopping":
|
||||||
self._rerun_grpc_url = bridge.grpc_url
|
|
||||||
if not self._closed and self._phase != "stopping":
|
|
||||||
self._phase = running_phase
|
self._phase = running_phase
|
||||||
self._message = (
|
self._message = "Мост Foxglove готов; источник данных запущен."
|
||||||
"Локальный Rerun-мост готов; источник данных запущен."
|
|
||||||
)
|
|
||||||
publisher_ready.set()
|
publisher_ready.set()
|
||||||
self._notify()
|
self._notify()
|
||||||
if publisher_aborted.is_set():
|
|
||||||
return
|
|
||||||
while not source_done.is_set() or not messages.empty():
|
while not source_done.is_set() or not messages.empty():
|
||||||
if self._stop_event.is_set() and source_done.is_set() and messages.empty():
|
if self._stop_event.is_set() and source_done.is_set() and messages.empty():
|
||||||
break
|
break
|
||||||
|
|
@ -345,51 +265,25 @@ class VisualizationRuntime:
|
||||||
if self._metrics.snapshot()["messages_received"] % 10 == 0:
|
if self._metrics.snapshot()["messages_received"] % 10 == 0:
|
||||||
self._notify()
|
self._notify()
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
with self._lock:
|
publisher_error.append(exc)
|
||||||
closed = self._closed
|
|
||||||
if not closed:
|
|
||||||
publisher_error.append(exc)
|
|
||||||
else:
|
|
||||||
publisher_aborted.set()
|
|
||||||
publisher_ready.set()
|
publisher_ready.set()
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
finally:
|
finally:
|
||||||
if bridge is not None:
|
if bridge is not None:
|
||||||
with self._lock:
|
bridge.close()
|
||||||
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:
|
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True)
|
||||||
# Never orphan a publisher: the session thread remains its owner.
|
|
||||||
# On process shutdown both are daemon threads, so an irrecoverably
|
|
||||||
# blocked native call cannot prevent the operating system from exit.
|
|
||||||
while publisher.is_alive():
|
|
||||||
publisher.join(timeout=0.25)
|
|
||||||
|
|
||||||
publisher = threading.Thread(target=publish, name="k1-rerun-publisher", daemon=True)
|
|
||||||
publisher.start()
|
publisher.start()
|
||||||
if not publisher_ready.wait(timeout=15.0):
|
if not publisher_ready.wait(timeout=15.0):
|
||||||
self._finish_error("Локальный мост визуализации не запустился за 15 секунд.")
|
self._finish_error("Мост Foxglove не запустился за 15 секунд.")
|
||||||
source_done.set()
|
source_done.set()
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
join_publisher()
|
|
||||||
return
|
|
||||||
if publisher_aborted.is_set():
|
|
||||||
source_done.set()
|
|
||||||
self._stop_event.set()
|
|
||||||
join_publisher()
|
|
||||||
return
|
return
|
||||||
if publisher_error:
|
if publisher_error:
|
||||||
self._finish_error(
|
self._finish_error(
|
||||||
"Ошибка локального моста визуализации: "
|
f"Ошибка моста Foxglove: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
|
||||||
f"{type(publisher_error[0]).__name__}: {publisher_error[0]}"
|
|
||||||
)
|
)
|
||||||
source_done.set()
|
source_done.set()
|
||||||
join_publisher()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
final_message = "Поток остановлен."
|
final_message = "Поток остановлен."
|
||||||
|
|
@ -401,8 +295,11 @@ class VisualizationRuntime:
|
||||||
self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}")
|
self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}")
|
||||||
finally:
|
finally:
|
||||||
source_done.set()
|
source_done.set()
|
||||||
join_publisher()
|
publisher.join(timeout=15.0)
|
||||||
if publisher_error:
|
if publisher.is_alive():
|
||||||
|
self._stop_event.set()
|
||||||
|
self._finish_error("Очередь публикации не завершилась за 15 секунд.")
|
||||||
|
elif publisher_error:
|
||||||
self._finish_error(
|
self._finish_error(
|
||||||
f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
|
f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
|
||||||
)
|
)
|
||||||
|
|
@ -433,10 +330,6 @@ class VisualizationRuntime:
|
||||||
self._foxglove_viewer_url = None
|
self._foxglove_viewer_url = None
|
||||||
self._notify()
|
self._notify()
|
||||||
|
|
||||||
def _current_scene_settings(self) -> RerunSceneSettings:
|
|
||||||
with self._lock:
|
|
||||||
return self._scene_settings
|
|
||||||
|
|
||||||
def _notify(self) -> None:
|
def _notify(self) -> None:
|
||||||
callback = self._on_state_change
|
callback = self._on_state_change
|
||||||
if callback is not None:
|
if callback is not None:
|
||||||
|
|
@ -463,7 +356,7 @@ def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: flo
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"started_at_utc": started_at_utc,
|
"started_at_utc": started_at_utc,
|
||||||
"started_monotonic_ns": time.monotonic_ns(),
|
"started_monotonic_ns": time.monotonic_ns(),
|
||||||
"operation": "k1_live_mqtt_to_rerun",
|
"operation": "k1_live_mqtt_to_foxglove",
|
||||||
"target": "owner-controlled K1 at redacted RFC1918 address",
|
"target": "owner-controlled K1 at redacted RFC1918 address",
|
||||||
"requested_duration_seconds": duration_seconds,
|
"requested_duration_seconds": duration_seconds,
|
||||||
"raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt",
|
"raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt",
|
||||||
|
|
@ -473,7 +366,7 @@ def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: flo
|
||||||
notes = (
|
notes = (
|
||||||
"# K1 live visualization session\n\n"
|
"# K1 live visualization session\n\n"
|
||||||
f"Started UTC: {started_at_utc}\n\n"
|
f"Started UTC: {started_at_utc}\n\n"
|
||||||
"The local control API started a read-only MQTT subscription and Rerun "
|
"The local control API started a read-only MQTT subscription and Foxglove "
|
||||||
"preview. Raw MQTT evidence is written before preview decoding. The target "
|
"preview. Raw MQTT evidence is written before preview decoding. The target "
|
||||||
f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n"
|
f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import AsyncIterator, Mapping
|
from collections.abc import Mapping
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any
|
||||||
|
|
||||||
from bleak.exc import BleakError
|
from bleak.exc import BleakError
|
||||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
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.scanner import scan
|
||||||
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
||||||
from k1link.mqtt import validate_private_ipv4
|
from k1link.mqtt import validate_private_ipv4
|
||||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
|
||||||
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
@ -45,17 +43,6 @@ class ReplayRequest(BaseModel):
|
||||||
loop: bool = False
|
loop: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ViewerSettingsRequest(BaseModel):
|
|
||||||
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
|
|
||||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
|
||||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
|
||||||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
|
||||||
accumulation_seconds: float = Field(default=12.0, ge=0.0, le=120.0)
|
|
||||||
show_points: bool = True
|
|
||||||
show_trajectory: bool = True
|
|
||||||
show_grid: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class ConsoleService:
|
class ConsoleService:
|
||||||
def __init__(self, repository_root: Path) -> None:
|
def __init__(self, repository_root: Path) -> None:
|
||||||
self.repository_root = repository_root.resolve()
|
self.repository_root = repository_root.resolve()
|
||||||
|
|
@ -93,13 +80,13 @@ class ConsoleService:
|
||||||
message = runtime["message"]
|
message = runtime["message"]
|
||||||
elif selected_device_id is not None:
|
elif selected_device_id is not None:
|
||||||
phase = "device_selected"
|
phase = "device_selected"
|
||||||
message = "Устройство выбрано. Теперь введите название и пароль Wi-Fi."
|
message = "K1 выбран. Теперь введите название и пароль Wi-Fi."
|
||||||
elif devices:
|
elif devices:
|
||||||
likely_count = sum(bool(item.get("likely_k1")) for item in devices)
|
likely_count = sum(bool(item.get("likely_k1")) for item in devices)
|
||||||
phase = "idle"
|
phase = "idle"
|
||||||
message = (
|
message = (
|
||||||
f"Найдено BLE-устройств: {len(devices)}. "
|
f"Найдено BLE-устройств: {len(devices)}. "
|
||||||
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
|
f"Возможных K1: {likely_count}. Выберите нужное устройство."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
phase = "idle"
|
phase = "idle"
|
||||||
|
|
@ -113,8 +100,6 @@ class ConsoleService:
|
||||||
"k1_ip": k1_ip,
|
"k1_ip": k1_ip,
|
||||||
"foxglove_ws_url": runtime["foxglove_ws_url"],
|
"foxglove_ws_url": runtime["foxglove_ws_url"],
|
||||||
"foxglove_viewer_url": runtime["foxglove_viewer_url"],
|
"foxglove_viewer_url": runtime["foxglove_viewer_url"],
|
||||||
"rerun_grpc_url": runtime["rerun_grpc_url"],
|
|
||||||
"viewer_settings": runtime["viewer_settings"],
|
|
||||||
"source_mode": runtime["source_mode"],
|
"source_mode": runtime["source_mode"],
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"pipeline_ms": metrics["mqtt_to_publish_ms"],
|
"pipeline_ms": metrics["mqtt_to_publish_ms"],
|
||||||
|
|
@ -159,7 +144,7 @@ class ConsoleService:
|
||||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||||
self._set_operation(
|
self._set_operation(
|
||||||
"provisioning",
|
"provisioning",
|
||||||
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
|
"Передаём в K1 настройки Wi-Fi одним подтверждённым запросом.",
|
||||||
)
|
)
|
||||||
session_dir = _new_operation_session_dir(
|
session_dir = _new_operation_session_dir(
|
||||||
self.repository_root,
|
self.repository_root,
|
||||||
|
|
@ -192,14 +177,12 @@ class ConsoleService:
|
||||||
)
|
)
|
||||||
if ipv4 is None:
|
if ipv4 is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
"K1 не сообщил адрес в локальной сети; автоматического повтора не было"
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._selected_device_id = request.device_id
|
self._selected_device_id = request.device_id
|
||||||
self._k1_ip = ipv4
|
self._k1_ip = ipv4
|
||||||
self._operation_message = (
|
self._operation_message = "K1 подключён к Wi-Fi и сообщил локальный адрес."
|
||||||
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
password = ""
|
password = ""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -210,7 +193,7 @@ class ConsoleService:
|
||||||
target = host or self.state()["k1_ip"]
|
target = host or self.state()["k1_ip"]
|
||||||
if not isinstance(target, str) or not target:
|
if not isinstance(target, str) or not target:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
|
"сначала подключите K1 к Wi-Fi или укажите его локальный адрес"
|
||||||
)
|
)
|
||||||
target = validate_private_ipv4(target)
|
target = validate_private_ipv4(target)
|
||||||
out_dir = new_live_session_dir(self.repository_root)
|
out_dir = new_live_session_dir(self.repository_root)
|
||||||
|
|
@ -228,21 +211,6 @@ class ConsoleService:
|
||||||
self.runtime.stop()
|
self.runtime.stop()
|
||||||
return self.state()
|
return self.state()
|
||||||
|
|
||||||
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
|
|
||||||
self.runtime.update_scene_settings(
|
|
||||||
RerunSceneSettings(
|
|
||||||
point_size=request.point_size,
|
|
||||||
color_mode=request.color_mode,
|
|
||||||
palette=request.palette,
|
|
||||||
custom_color=request.custom_color,
|
|
||||||
accumulation_seconds=request.accumulation_seconds,
|
|
||||||
show_points=request.show_points,
|
|
||||||
show_trajectory=request.show_trajectory,
|
|
||||||
show_grid=request.show_grid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return self.state()
|
|
||||||
|
|
||||||
def _set_operation(self, phase: str, message: str) -> None:
|
def _set_operation(self, phase: str, message: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._operation_phase = phase
|
self._operation_phase = phase
|
||||||
|
|
@ -250,23 +218,12 @@ class ConsoleService:
|
||||||
|
|
||||||
|
|
||||||
service = ConsoleService(REPOSITORY_ROOT)
|
service = ConsoleService(REPOSITORY_ROOT)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
service.runtime.close()
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="NODE.DC Device Control API",
|
title="NODE.DC K1 Live Console API",
|
||||||
version=__version__,
|
version=__version__,
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
lifespan=app_lifespan,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -300,7 +257,7 @@ async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||||
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
|
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=502,
|
status_code=502,
|
||||||
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
|
detail=f"Ошибка подключения K1 к Wi-Fi: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -325,11 +282,6 @@ def stop_session() -> dict[str, Any]:
|
||||||
return service.stop()
|
return service.stop()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/viewer/settings")
|
|
||||||
def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
|
||||||
return service.update_viewer_settings(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/api/events")
|
@app.websocket("/api/events")
|
||||||
async def events(websocket: WebSocket) -> None:
|
async def events(websocket: WebSocket) -> None:
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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]
|
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
97
uv.lock
|
|
@ -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" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "attrs"
|
|
||||||
version = "26.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bleak"
|
name = "bleak"
|
||||||
version = "3.0.2"
|
version = "3.0.2"
|
||||||
|
|
@ -278,7 +269,6 @@ dependencies = [
|
||||||
{ name = "foxglove-sdk" },
|
{ name = "foxglove-sdk" },
|
||||||
{ name = "lz4" },
|
{ name = "lz4" },
|
||||||
{ name = "paho-mqtt" },
|
{ name = "paho-mqtt" },
|
||||||
{ name = "rerun-sdk" },
|
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
|
@ -298,7 +288,6 @@ requires-dist = [
|
||||||
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
||||||
{ name = "lz4", specifier = ">=4.4,<5" },
|
{ name = "lz4", specifier = ">=4.4,<5" },
|
||||||
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
||||||
{ name = "rerun-sdk", specifier = "==0.34.1" },
|
|
||||||
{ name = "rich", specifier = ">=13.9,<15" },
|
{ name = "rich", specifier = ">=13.9,<15" },
|
||||||
{ name = "typer", specifier = ">=0.15,<1" },
|
{ name = "typer", specifier = ">=0.15,<1" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
|
||||||
|
|
@ -311,25 +300,6 @@ dev = [
|
||||||
{ name = "ruff", specifier = ">=0.11,<1" },
|
{ name = "ruff", specifier = ">=0.11,<1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "numpy"
|
|
||||||
version = "2.5.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.2"
|
||||||
|
|
@ -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" },
|
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pillow"
|
|
||||||
version = "12.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
|
@ -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" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "psutil"
|
|
||||||
version = "7.2.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyarrow"
|
|
||||||
version = "25.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.13.4"
|
||||||
|
|
@ -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" },
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rerun-sdk"
|
|
||||||
version = "0.34.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "attrs" },
|
|
||||||
{ name = "numpy" },
|
|
||||||
{ name = "pillow" },
|
|
||||||
{ name = "psutil" },
|
|
||||||
{ name = "pyarrow" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/37/41422eca73b5f933872ad073d17034f47ccac730fc4a29b9064d73c74424/rerun_sdk-0.34.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:32d3fb3e9f46eb84433427dc6add6ef7508bbd36295dcc80d693dccacf936e6b", size = 133584987, upload-time = "2026-07-07T17:43:14.934Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/f7/c2e5097f0138a3c4d70e2fe1c24781fac9aec351324c2ea5b15f4d6971b8/rerun_sdk-0.34.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:57585f9c77af6d8a5ee0342bd79b87431b632bf7158e9b8dd4756d6ccd7b7feb", size = 142889929, upload-time = "2026-07-07T17:43:20.293Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/fa/f87899a8c0cc36901d32069072340b894654fa6f3b3a2a74913565c3661a/rerun_sdk-0.34.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:60c35adb49f04bd64bf1a426b59f61aeee8312fa7cc2624878b43186e817b2d4", size = 147505605, upload-time = "2026-07-07T17:43:25.397Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/3a/07125af48ef024c573acadf1bfedd406bcc838ffa1af0d7b20bd79895267/rerun_sdk-0.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:3448b34d385994a32caaa4e3ffd42b69069ab7791382d8936089e4928ca24cd6", size = 126554967, upload-time = "2026-07-07T17:43:30.61Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "14.3.4"
|
version = "14.3.4"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue