feat: build NODE.DC control station shell
This commit is contained in:
parent
8b5b90d8af
commit
6402601d6d
64
README.md
64
README.md
|
|
@ -55,45 +55,57 @@ uv run k1link doctor
|
||||||
uv run pytest
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Live console and Foxglove
|
## NODE.DC Control Station and visualization adapters
|
||||||
|
|
||||||
Build the browser control surface once, then launch the whole local stand from
|
The browser application is now a universal NODE.DC Control Station rather than
|
||||||
the repository root:
|
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
|
||||||
|
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
|
||||||
|
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter.
|
||||||
|
|
||||||
|
Install, type-check, build and serve the complete local application from the
|
||||||
|
repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
uv sync --group dev
|
||||||
cd apps/k1-viewer
|
cd apps/k1-viewer
|
||||||
npm install
|
npm install
|
||||||
|
npm run typecheck
|
||||||
npm run build
|
npm run build
|
||||||
cd ../..
|
cd ../..
|
||||||
uv run k1link serve
|
uv run k1link serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://127.0.0.1:8000`. The API, credential form and Foxglove WebSocket
|
Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
|
||||||
bind to loopback only. The console supports:
|
plane and credential endpoint bind to loopback only. The current K1 adapter
|
||||||
|
still provides real CoreBluetooth discovery, one operator-triggered reviewed
|
||||||
|
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
|
||||||
|
reviewed-TSV replay, raw-first evidence storage and measured preview metrics.
|
||||||
|
Physical K1 scanning is still started and stopped by the verified double-click;
|
||||||
|
the connector publishes no modeling command.
|
||||||
|
|
||||||
- real CoreBluetooth K1 discovery;
|
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
||||||
- one operator-triggered reviewed BLE Wi-Fi provisioning write;
|
inside the NODE.DC shell. It can open a compatible RRD file over HTTP(S) or a
|
||||||
- live read-only MQTT capture with raw-first evidence storage;
|
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
|
||||||
- replay of native `.k1mqtt` evidence and the reviewed four-column TSV capture;
|
use an external hosted viewer UI.
|
||||||
- `/k1/points`, `/k1/pose`, `/k1/trajectory` and `/k1/metrics` for Foxglove;
|
|
||||||
- measured Mac-side MQTT-receive-to-Foxglove-publish latency and bounded
|
|
||||||
latest-wins preview dropping.
|
|
||||||
|
|
||||||
For live mode, start the session in the console and use the verified physical
|
There is one important current boundary: the automatic K1 MQTT → Rerun
|
||||||
double-click on K1 to start or stop scanning. The connector deliberately does
|
RRD/gRPC contract is **not implemented yet**. Starting a K1 live or replay
|
||||||
not publish modeling commands yet. For the recorded proof, replay this ignored
|
session updates real state and metrics, but it does not by itself populate the
|
||||||
local file at `1x` with loop enabled:
|
embedded Rerun viewport. The scene controls for point size, gradients,
|
||||||
|
accumulation, layers, timeline and layout are presently a product UI contract;
|
||||||
|
their Rerun Blueprint/playback adapter is also still pending. No synthetic
|
||||||
|
point cloud, trajectory, camera frame or latency value is generated to conceal
|
||||||
|
these missing links.
|
||||||
|
|
||||||
```text
|
The existing Python MQTT → Foxglove pipeline remains in place as the verified
|
||||||
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
|
legacy visualization adapter. It still publishes `/k1/points`, `/k1/pose`,
|
||||||
```
|
`/k1/trajectory` and `/k1/metrics`, and preserves the measured
|
||||||
|
MQTT-receive-to-Foxglove-publish latency plus bounded latest-wins preview
|
||||||
Foxglove remains the full 3D workspace rather than being reimplemented in the
|
dropping. It is retained for evidence, regression and diagnostics, not as the
|
||||||
React console. In its 3D panel, select `/k1/points`, color by `intensity` (or
|
target Control Station interface. The historical
|
||||||
`z`/`<distance>`) and choose Turbo, Rainbow or a custom gradient. Add
|
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records that proven path and its
|
||||||
`/k1/trajectory` for the cyan path and `/k1/pose` for the current pose. See the
|
timing semantics; the current frontend contract is documented in
|
||||||
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) for timing semantics and current
|
[`apps/k1-viewer/README.md`](apps/k1-viewer/README.md).
|
||||||
limitations.
|
|
||||||
|
|
||||||
`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
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,224 @@
|
||||||
# K1 Live Console
|
# Пункт управления NODE.DC
|
||||||
|
|
||||||
React 19 + TypeScript + Vite frontend for the local K1 bridge. The console handles
|
Универсальный браузерный пункт управления NODE.DC на React 19, TypeScript и
|
||||||
connection setup, source selection, transport status, latency metrics and handoff to
|
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
|
||||||
the Foxglove 3D viewer. It does not emulate scanner operations or generate placeholder
|
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
|
||||||
telemetry.
|
device adapter, но структура интерфейса от него не зависит.
|
||||||
|
|
||||||
## Run locally
|
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
|
||||||
|
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
|
||||||
|
переход во внешний облачный интерфейс. Сам браузерный компонент, однако, должен
|
||||||
|
получить совместимый источник RRD или Rerun gRPC.
|
||||||
|
|
||||||
|
## Текущее состояние
|
||||||
|
|
||||||
|
| Контур | Состояние | Что это означает |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| NODE.DC fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
|
||||||
|
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||||
|
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||||
|
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||||
|
| Legacy Foxglove adapter | Реализован | Текущий Python runtime публикует декодированные K1 данные в локальный Foxglove WebSocket. Это проверенный диагностический backend, а не продуктовый UI. |
|
||||||
|
| Встроенный Rerun Viewer | Реализован | Открывает назначенный RRD по HTTP(S) или совместимый Rerun gRPC/proxy source внутри Control Station. |
|
||||||
|
| Автоматический MQTT → Rerun | **Не реализован** | Запуск K1 live/replay пока не создаёт RRD и не подаёт данные во встроенный Rerun viewport. Нужен отдельный stream adapter. |
|
||||||
|
| Контролы сцены → Rerun Blueprint | **Не реализованы** | Размер точек, палитра, накопление, слои, проекция, timeline и компоновка пока являются честным UI-контрактом и состоянием текущего сеанса. |
|
||||||
|
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
|
||||||
|
|
||||||
|
Приложение не генерирует демонстрационное облако, траекторию, кадры или
|
||||||
|
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
|
||||||
|
показывают `—`.
|
||||||
|
|
||||||
|
## Архитектура данных
|
||||||
|
|
||||||
|
```text
|
||||||
|
NODE.DC Control Station в браузере
|
||||||
|
├── control plane
|
||||||
|
│ └── REST /api/* + WebSocket /api/events
|
||||||
|
│ └── FastAPI на 127.0.0.1:8000
|
||||||
|
│ ├── CoreBluetooth: discovery и reviewed Wi-Fi provisioning
|
||||||
|
│ └── live/replay session runtime
|
||||||
|
│
|
||||||
|
├── текущий K1 preview path — legacy adapter
|
||||||
|
│ └── K1 MQTT :1883, read-only
|
||||||
|
│ ├── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
|
||||||
|
│ └── protobuf/LZ4 decoder
|
||||||
|
│ └── Foxglove bridge → локальный WebSocket
|
||||||
|
│
|
||||||
|
└── продуктовая пространственная сцена
|
||||||
|
└── встроенный @rerun-io/web-viewer
|
||||||
|
└── RRD URL или Rerun gRPC/proxy source
|
||||||
|
```
|
||||||
|
|
||||||
|
Между декодером K1 и Rerun Viewer сейчас нет автоматической стрелки. Поля
|
||||||
|
`foxglove_ws_url` и `foxglove_viewer_url` ещё присутствуют в API только потому,
|
||||||
|
что Python runtime продолжает поднимать проверенный legacy adapter. Пункт
|
||||||
|
управления не использует их как основную поверхность визуализации.
|
||||||
|
|
||||||
|
## Фиксированная оболочка
|
||||||
|
|
||||||
|
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
|
||||||
|
всех функциональных модулей:
|
||||||
|
|
||||||
|
1. `AppHeader` — марка NODE.DC, выбор архитектурного раздела и состояние
|
||||||
|
локального backend.
|
||||||
|
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
|
||||||
|
выбранного раздела.
|
||||||
|
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
|
||||||
|
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
|
||||||
|
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
|
||||||
|
раскрытия внутренних панелей визуального движка.
|
||||||
|
|
||||||
|
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
|
||||||
|
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
|
||||||
|
Control Station. Это ещё не означает, что все продуктовые контролы уже связаны
|
||||||
|
с API Rerun: текущая граница явно показана статусом `Интерфейс готов`.
|
||||||
|
|
||||||
|
## Архитектурные разделы
|
||||||
|
|
||||||
|
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
|
||||||
|
страницы.
|
||||||
|
|
||||||
|
| Раздел | Назначение |
|
||||||
|
| --- | --- |
|
||||||
|
| Центр | Оперативный обзор, состояние контура и активность оператора. |
|
||||||
|
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
|
||||||
|
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
|
||||||
|
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
|
||||||
|
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
|
||||||
|
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
|
||||||
|
|
||||||
|
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
|
||||||
|
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
|
||||||
|
как утверждение, что для каждой карточки уже существует backend.
|
||||||
|
|
||||||
|
## Установка, проверка, сборка и запуск
|
||||||
|
|
||||||
|
Требуются Node.js 20 или новее, `uv` и соседний checkout
|
||||||
|
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
|
||||||
|
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
|
||||||
|
|
||||||
|
Канонический путь от корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
|
||||||
|
uv sync --group dev
|
||||||
|
|
||||||
|
cd apps/k1-viewer
|
||||||
|
npm install
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd ../..
|
||||||
|
uv run k1link serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
|
||||||
|
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
|
||||||
|
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
|
||||||
|
Wi-Fi.
|
||||||
|
|
||||||
|
Для разработки интерфейса после запуска backend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/k1-viewer
|
cd apps/k1-viewer
|
||||||
npm install
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The development server listens on `http://127.0.0.1:5173` and proxies `/api` to
|
Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на
|
||||||
`http://127.0.0.1:8000`. Set `VITE_API_TARGET` in a local `.env` file to use a
|
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
|
||||||
different backend address. Production artifacts are built with `npm run build`.
|
`VITE_API_TARGET`. Preview production-сборки запускается командой
|
||||||
|
`npm run preview` на `http://127.0.0.1:4173`.
|
||||||
|
|
||||||
The NODE.DC UI packages are consumed from the sibling `NODEDC_DESIGN_GUIDELINE`
|
## Операторский путь для текущего K1 adapter
|
||||||
checkout through `file:` dependencies. Their source is not copied into this app.
|
|
||||||
|
|
||||||
## Backend contract
|
1. Запустить `uv run k1link serve` и открыть Control Station.
|
||||||
|
2. Выбрать **Парк → Локальное устройство**.
|
||||||
|
3. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
|
||||||
|
4. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
|
||||||
|
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
|
||||||
|
оператор.
|
||||||
|
5. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
|
||||||
|
reviewed provisioning-запись без автоматических повторов.
|
||||||
|
6. Запустить live-приём по определённому адресу K1 либо replay локального
|
||||||
|
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
|
||||||
|
останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||||
|
7. Состояние, частота, число точек, задержка и пропуски preview появятся только
|
||||||
|
после прихода реальных данных.
|
||||||
|
|
||||||
| Method | Route | Body / purpose |
|
Этот путь запускает существующий MQTT/Foxglove runtime. Чтобы увидеть тот же
|
||||||
|
живой K1 поток во встроенной пространственной сцене, нужно сначала реализовать и
|
||||||
|
запустить MQTT→Rerun stream adapter; одного запуска live-сессии сейчас
|
||||||
|
недостаточно.
|
||||||
|
|
||||||
|
## Источники Rerun
|
||||||
|
|
||||||
|
В **Наблюдение → Пространственная сцена → Источник** принимается URL, который
|
||||||
|
понимает `@rerun-io/web-viewer` версии, зафиксированной в `package.json`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rerun+http://127.0.0.1:9876/proxy
|
||||||
|
http://127.0.0.1:8080/recording.rrd
|
||||||
|
https://example.internal/recording.rrd
|
||||||
|
```
|
||||||
|
|
||||||
|
- `rerun+http://…/proxy` — живой Rerun gRPC source через доступный браузеру
|
||||||
|
proxy;
|
||||||
|
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
|
||||||
|
- версия RRD должна быть совместима с версией Web Viewer;
|
||||||
|
- URL хранится только в состоянии текущей страницы;
|
||||||
|
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
|
||||||
|
|
||||||
|
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
|
||||||
|
оболочку. Продуктовые настройки отображения, custom timeline, переключатель
|
||||||
|
2D/3D/карты и сохранение RBL пока не вызывают Blueprint/playback API. Черновик
|
||||||
|
компоновки фиксируется только в памяти текущей страницы и не записывается на
|
||||||
|
диск.
|
||||||
|
|
||||||
|
## Контракт локального API
|
||||||
|
|
||||||
|
| Метод | Route | Тело / назначение |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET` | `/api/health` | Local service health |
|
| `GET` | `/api/health` | Проверка локального сервиса. |
|
||||||
| `GET` | `/api/state` | Authoritative console state |
|
| `GET` | `/api/state` | Авторитетный snapshot состояния. |
|
||||||
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }` |
|
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. |
|
||||||
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }` |
|
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. |
|
||||||
| `POST` | `/api/session/live` | Optional `{ "host", "duration_seconds" }` |
|
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
|
||||||
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }` |
|
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
|
||||||
| `POST` | `/api/session/stop` | Stop the active source |
|
| `POST` | `/api/session/stop` | Остановка активного источника. |
|
||||||
| `WS` | `/api/events` | State snapshots for live UI updates |
|
| `WS` | `/api/events` | Периодические snapshots для live UI. |
|
||||||
|
|
||||||
`/api/state` and state-changing responses may return the snapshot directly or as
|
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
|
||||||
`{ "state": { ... } }`. A snapshot exposes `phase`, `message`, `devices`,
|
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
|
||||||
`selected_device_id`, `k1_ip`, `foxglove_ws_url`, `foxglove_viewer_url`,
|
`selected_device_id`, `k1_ip`, `source_mode`, `metrics` и legacy-поля
|
||||||
`source_mode` and `metrics`.
|
`foxglove_ws_url`/`foxglove_viewer_url`. OpenAPI доступен по `/api/docs`.
|
||||||
|
|
||||||
The Wi-Fi password remains only in React memory, is sent in the JSON POST body, and is
|
## Карта исходников frontend
|
||||||
cleared after the backend acknowledges a successful connection. It is never written to
|
|
||||||
local storage or included in a URL.
|
|
||||||
|
|
||||||
## Explicit unavailable states
|
| Файл | Ответственность |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
|
||||||
|
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
|
||||||
|
| `src/workspaces/DeviceWorkspace.tsx` | Реальный K1 BLE/Wi-Fi/live/replay adapter UI. |
|
||||||
|
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
||||||
|
| `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
|
||||||
|
| `src/api.ts` | REST/WebSocket контракт с FastAPI. |
|
||||||
|
| `src/useK1Console.ts` | Состояние backend, polling, события и действия оператора. |
|
||||||
|
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
|
||||||
|
| `src/presentation.ts` | Русские подписи фаз и форматирование реальных метрик. |
|
||||||
|
| `src/messages.ts` | Обезличивание и локализация технических сообщений в пользовательском интерфейсе. |
|
||||||
|
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
|
||||||
|
|
||||||
- Missing metrics render as an em dash; no synthetic values are used.
|
## Safety и чувствительные данные
|
||||||
- BLE scanning remains a real API action and reports HTTP/network errors visibly.
|
|
||||||
- The Foxglove button is disabled until the backend supplies
|
- Control Station и credential endpoint доступны только на loopback.
|
||||||
`foxglove_viewer_url`.
|
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
|
||||||
- Replay cannot start without a backend-local capture path.
|
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||||
|
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
|
||||||
|
случайные GATT writes и автоматические повторы запрещены.
|
||||||
|
- MQTT live/replay не публикует команды устройству. Запуск и остановка
|
||||||
|
физического сканирования остаются за кнопкой K1.
|
||||||
|
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
|
||||||
|
перегрузке preview может быть отброшен, raw evidence сохраняется.
|
||||||
|
- `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="Локальная консоль подключения XGRIDS Lixel K1 и просмотра облака точек в реальном времени."
|
content="Пункт управления NODE.DC — наблюдение, данные и планирование миссий беспилотных аппаратов."
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#08090b" />
|
<meta name="theme-color" content="#08090b" />
|
||||||
<title>K1 · Облако точек в реальном времени · NODE.DC</title>
|
<title>NODE.DC · Пункт управления</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
|
"@rerun-io/web-viewer": "0.34.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -20,7 +21,8 @@
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0",
|
||||||
|
"vite-plugin-wasm": "^3.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|
@ -844,6 +846,12 @@
|
||||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@rerun-io/web-viewer": {
|
||||||
|
"version": "0.34.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.34.1.tgz",
|
||||||
|
"integrity": "sha512-2Oq9Mw3qOs765XArGq4e/0pAIksPFKlAqkgo+PDsRkPd77/KdnQhb835suRmYQ6tuqlbPeVCFhlNIXT0+TjmTg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -1880,6 +1888,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-wasm": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
|
"@rerun-io/web-viewer": "0.34.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -22,7 +23,8 @@
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0",
|
||||||
|
"vite-plugin-wasm": "^3.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55">
|
||||||
|
<defs>
|
||||||
|
<style>.cls-1{fill:#e2e1e1}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:.75px}</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||||
|
<polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||||
|
<path class="cls-2" d="M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15ZM140.43 18.64c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09ZM188.88 18.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75ZM205.15 9.75c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55">
|
||||||
|
<path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||||
|
<polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
File diff suppressed because it is too large
Load Diff
|
|
@ -99,7 +99,7 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new ApiError("Не удалось подключиться к локальному API K1.");
|
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyText = await response.text();
|
const bodyText = await response.text();
|
||||||
|
|
@ -119,9 +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 K1 завершился ошибкой HTTP ${response.status}.`;
|
: `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
detail || `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`,
|
detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
|
||||||
response.status,
|
response.status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -203,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, "Консоль K1 закрыта");
|
return () => socket.close(1000, "Пункт управления закрыт");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type { RootDefinition } from "../productModel";
|
||||||
|
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||||
|
import type { BackendStatus } from "../useK1Console";
|
||||||
|
|
||||||
|
export interface LandingStageProps {
|
||||||
|
root: RootDefinition | null;
|
||||||
|
backendStatus: BackendStatus;
|
||||||
|
phase?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
onOpenObservation: () => void;
|
||||||
|
onOpenDevice: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LandingStage({
|
||||||
|
root,
|
||||||
|
backendStatus,
|
||||||
|
phase,
|
||||||
|
message,
|
||||||
|
onOpenObservation,
|
||||||
|
onOpenDevice,
|
||||||
|
}: LandingStageProps) {
|
||||||
|
return (
|
||||||
|
<section className="landing-stage" data-root={root?.id ?? "home"}>
|
||||||
|
<div className="landing-stage__field" aria-hidden="true">
|
||||||
|
<span className="landing-stage__grid" />
|
||||||
|
<span className="landing-stage__orbit landing-stage__orbit--one" />
|
||||||
|
<span className="landing-stage__orbit landing-stage__orbit--two" />
|
||||||
|
<span className="landing-stage__orbit landing-stage__orbit--three" />
|
||||||
|
<span className="landing-stage__sweep" />
|
||||||
|
<span className="landing-stage__node landing-stage__node--one" />
|
||||||
|
<span className="landing-stage__node landing-stage__node--two" />
|
||||||
|
<span className="landing-stage__node landing-stage__node--three" />
|
||||||
|
<span className="landing-stage__node landing-stage__node--four" />
|
||||||
|
</div>
|
||||||
|
<div className="landing-stage__shade" />
|
||||||
|
|
||||||
|
<div className="landing-stage__copy">
|
||||||
|
<span className="section-eyebrow">{root?.eyebrow ?? "NODE.DC / ПУНКТ УПРАВЛЕНИЯ"}</span>
|
||||||
|
<h1>{root?.title ?? "Пункт управления"}</h1>
|
||||||
|
<p>
|
||||||
|
{root?.statement ??
|
||||||
|
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
|
||||||
|
</p>
|
||||||
|
<div className="landing-stage__actions">
|
||||||
|
<Button variant="accent" icon={<Icon name="globe" />} onClick={onOpenObservation}>
|
||||||
|
Пространственная сцена
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}>
|
||||||
|
Локальное устройство
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="landing-stage__status">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
|
||||||
|
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">ИСТОЧНИК</span>
|
||||||
|
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="landing-stage__footer">
|
||||||
|
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
|
||||||
|
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { GlassSurface } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
export interface MetricCardProps {
|
||||||
|
eyebrow: string;
|
||||||
|
value: string;
|
||||||
|
unit?: string;
|
||||||
|
detail: string;
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricCard({ eyebrow, value, unit, detail, featured = false }: MetricCardProps) {
|
||||||
|
return (
|
||||||
|
<GlassSurface className="metric-card" padding="md" data-featured={featured ? "true" : undefined}>
|
||||||
|
<span className="metric-card__eyebrow">{eyebrow}</span>
|
||||||
|
<div className="metric-card__reading">
|
||||||
|
<strong>{value}</strong>
|
||||||
|
{unit ? <span>{unit}</span> : null}
|
||||||
|
</div>
|
||||||
|
<p>{detail}</p>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export interface RerunSelection {
|
||||||
|
entityPath: string;
|
||||||
|
viewName?: string;
|
||||||
|
position?: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RerunViewportProps {
|
||||||
|
sourceUrl: string;
|
||||||
|
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
|
||||||
|
onSelectionChange?: (selection: RerunSelection | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RerunViewport({
|
||||||
|
sourceUrl,
|
||||||
|
onStatusChange,
|
||||||
|
onSelectionChange,
|
||||||
|
}: RerunViewportProps) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalizedSource = sourceUrl.trim();
|
||||||
|
if (!normalizedSource || !hostRef.current) {
|
||||||
|
setStatus("idle");
|
||||||
|
onStatusChange?.("idle");
|
||||||
|
onSelectionChange?.(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let started = false;
|
||||||
|
let stopViewer: (() => void) | undefined;
|
||||||
|
const unsubscribers: Array<() => void> = [];
|
||||||
|
setStatus("loading");
|
||||||
|
onStatusChange?.("loading");
|
||||||
|
|
||||||
|
void import("@rerun-io/web-viewer")
|
||||||
|
.then(async ({ WebViewer }) => {
|
||||||
|
const viewer = new WebViewer();
|
||||||
|
stopViewer = () => viewer.stop();
|
||||||
|
|
||||||
|
await viewer.start(
|
||||||
|
normalizedSource,
|
||||||
|
hostRef.current,
|
||||||
|
{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
theme: "dark",
|
||||||
|
hide_welcome_screen: true,
|
||||||
|
enable_history: false,
|
||||||
|
allow_fullscreen: false,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
if (disposed) {
|
||||||
|
viewer.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer.override_panel_state("top", "hidden");
|
||||||
|
viewer.override_panel_state("blueprint", "hidden");
|
||||||
|
viewer.override_panel_state("selection", "hidden");
|
||||||
|
viewer.override_panel_state("time", "hidden");
|
||||||
|
|
||||||
|
unsubscribers.push(
|
||||||
|
viewer.on("selection_change", (event) => {
|
||||||
|
const entity = event.items.find((item) => item.type === "entity");
|
||||||
|
if (!entity || entity.type !== "entity") {
|
||||||
|
onSelectionChange?.(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectionChange?.({
|
||||||
|
entityPath: entity.entity_path,
|
||||||
|
viewName: entity.view_name,
|
||||||
|
position: entity.position,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setStatus("ready");
|
||||||
|
onStatusChange?.("ready");
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (disposed) return;
|
||||||
|
const message = error instanceof Error ? error.message : "Не удалось запустить визуальный движок.";
|
||||||
|
setStatus("error");
|
||||||
|
onStatusChange?.("error", message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||||
|
if (started) stopViewer?.();
|
||||||
|
onSelectionChange?.(null);
|
||||||
|
};
|
||||||
|
}, [onSelectionChange, onStatusChange, sourceUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rerun-viewport" data-status={status}>
|
||||||
|
<div ref={hostRef} className="rerun-viewport__canvas" />
|
||||||
|
{status === "loading" ? (
|
||||||
|
<div className="rerun-viewport__notice" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Подключаем визуальный источник</strong>
|
||||||
|
<span>Запуск встроенного визуализатора Rerun.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{status === "error" ? (
|
||||||
|
<div className="rerun-viewport__notice rerun-viewport__notice--error" role="alert">
|
||||||
|
<div>
|
||||||
|
<strong>Источник не открыт</strong>
|
||||||
|
<span>Проверьте адрес RRD или Rerun gRPC в настройках источника.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ import "./styles.css";
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("K1 console root element is missing.");
|
throw new Error("Не найден корневой элемент пункта управления.");
|
||||||
}
|
}
|
||||||
|
|
||||||
rootElement.classList.add("nodedc-ui-root");
|
rootElement.classList.add("nodedc-ui-root");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||||
|
[/broker connection ended/gi, "соединение с брокером завершено"],
|
||||||
|
[/Unspecified error/gi, "неуказанная ошибка"],
|
||||||
|
[
|
||||||
|
/Device was not rediscovered; keep the K1 powered and nearby\.?/gi,
|
||||||
|
"Устройство не обнаружено повторно; оставьте его включённым и рядом.",
|
||||||
|
],
|
||||||
|
[/Foxglove/gi, "локальный мост визуализации"],
|
||||||
|
[/MacBook/gi, "компьютер"],
|
||||||
|
[/\bK1\b/g, "устройство"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return runtimeMessageReplacements.reduce(
|
||||||
|
(localized, [pattern, replacement]) => localized.replace(pattern, replacement),
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { StatusTone } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type { K1Metrics } from "./api";
|
||||||
|
import type { BackendStatus } from "./useK1Console";
|
||||||
|
|
||||||
|
const phaseLabels: Record<string, string> = {
|
||||||
|
idle: "Ожидание",
|
||||||
|
scanning: "Поиск Bluetooth",
|
||||||
|
device_selected: "Устройство выбрано",
|
||||||
|
provisioning: "Передача настроек 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.decode_ms),
|
||||||
|
finiteMetric(metrics.publish_ms),
|
||||||
|
].filter((value): value is number => value !== null);
|
||||||
|
|
||||||
|
return segments.length ? segments.reduce((total, value) => total + value, 0) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(value: number | null, digits = 1): string {
|
||||||
|
if (value === null) return "—";
|
||||||
|
return value.toLocaleString("ru-RU", {
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceModeLabel(mode: string | null | undefined): string {
|
||||||
|
if (mode === "live") return "Реальное время";
|
||||||
|
if (mode === "replay") return "Повтор записи";
|
||||||
|
if (mode === "idle") return "Ожидание";
|
||||||
|
return "Неизвестно";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,758 @@
|
||||||
|
import type { IconName } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
export type RootId = "center" | "fleet" | "observation" | "missions" | "data" | "system";
|
||||||
|
|
||||||
|
export type WorkspaceKind =
|
||||||
|
| "overview"
|
||||||
|
| "device"
|
||||||
|
| "spatial"
|
||||||
|
| "cameras"
|
||||||
|
| "map"
|
||||||
|
| "timeline"
|
||||||
|
| "missions"
|
||||||
|
| "catalog";
|
||||||
|
|
||||||
|
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
||||||
|
|
||||||
|
export interface Capability {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
status: CapabilityStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityGroup {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
capabilities: Capability[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceDefinition {
|
||||||
|
id: string;
|
||||||
|
root: RootId;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
eyebrow: string;
|
||||||
|
description: string;
|
||||||
|
icon: IconName;
|
||||||
|
kind: WorkspaceKind;
|
||||||
|
groups: CapabilityGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RootDefinition {
|
||||||
|
id: RootId;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
eyebrow: string;
|
||||||
|
description: string;
|
||||||
|
statement: string;
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = (label: string, description: string): Capability => ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = (label: string, description: string): Capability => ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
status: "ready",
|
||||||
|
});
|
||||||
|
|
||||||
|
const contract = (label: string, description: string): Capability => ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
status: "contract",
|
||||||
|
});
|
||||||
|
|
||||||
|
const later = (label: string, description: string): Capability => ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
status: "later",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const roots: RootDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "center",
|
||||||
|
label: "Центр",
|
||||||
|
title: "Центр управления",
|
||||||
|
eyebrow: "ПУНКТ УПРАВЛЕНИЯ",
|
||||||
|
description: "Единая оперативная картина, состояние контура и события оператора.",
|
||||||
|
statement: "Наблюдать весь контур, быстро видеть отклонения и переходить к действию.",
|
||||||
|
accent: "ОПЕРАТИВНЫЙ КОНТУР",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fleet",
|
||||||
|
label: "Парк",
|
||||||
|
title: "Аппараты и устройства",
|
||||||
|
eyebrow: "ПАРК / УСТРОЙСТВА",
|
||||||
|
description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.",
|
||||||
|
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
|
||||||
|
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "observation",
|
||||||
|
label: "Наблюдение",
|
||||||
|
title: "Наблюдение",
|
||||||
|
eyebrow: "СИТУАЦИОННАЯ ОСВЕДОМЛЁННОСТЬ",
|
||||||
|
description: "Пространственная сцена, камеры, карты, объекты, телеметрия и время.",
|
||||||
|
statement: "Свести все сенсоры в синхронную и управляемую операторскую картину.",
|
||||||
|
accent: "ЗРЕНИЕ И ПРОСТРАНСТВО",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "missions",
|
||||||
|
label: "Миссии",
|
||||||
|
title: "Миссии",
|
||||||
|
eyebrow: "УПРАВЛЕНИЕ МИССИЯМИ",
|
||||||
|
description: "Планирование, маршруты, сценарии и наблюдение за исполнением.",
|
||||||
|
statement: "Собрать задачу из точек, ограничений и действий до передачи на борт.",
|
||||||
|
accent: "ПЛАНИРОВАНИЕ И ИСПОЛНЕНИЕ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "data",
|
||||||
|
label: "Данные",
|
||||||
|
title: "Данные и записи",
|
||||||
|
eyebrow: "РАБОЧАЯ ОБЛАСТЬ ДАННЫХ",
|
||||||
|
description: "Сессии, потоки, сущности, воспроизведение и экспорт доказательств.",
|
||||||
|
statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.",
|
||||||
|
accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system",
|
||||||
|
label: "Система",
|
||||||
|
title: "Система",
|
||||||
|
eyebrow: "УПРАВЛЕНИЕ ПЛАТФОРМОЙ",
|
||||||
|
description: "Модули, интеграции, сеть, аудит и параметры операторского пункта.",
|
||||||
|
statement: "Подключать новые возможности модульно, не связывая интерфейс с одним устройством.",
|
||||||
|
accent: "МОДУЛИ И ИНТЕГРАЦИИ",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const workspaces: WorkspaceDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "command-overview",
|
||||||
|
root: "center",
|
||||||
|
label: "Оперативный обзор",
|
||||||
|
title: "Оперативный обзор",
|
||||||
|
eyebrow: "ЦЕНТР / СЕЙЧАС",
|
||||||
|
description: "Живое состояние локального контура и быстрые переходы к рабочим поверхностям.",
|
||||||
|
icon: "activity",
|
||||||
|
kind: "overview",
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
title: "Оперативная картина",
|
||||||
|
description: "Состояние, которое уже приходит из локального адаптера.",
|
||||||
|
capabilities: [
|
||||||
|
active("Состояние устройства", "Фаза подключения, адрес и активный режим источника."),
|
||||||
|
active("Метрики потока", "Частота кадров, точки в кадре, задержка и пропуски предпросмотра."),
|
||||||
|
ready("Единый индикатор готовности", "Нормализованная готовность сенсоров и транспорта."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Будущий контур",
|
||||||
|
description: "Сводка нескольких аппаратов без изменения оболочки.",
|
||||||
|
capabilities: [
|
||||||
|
contract("Парк на карте", "Положение, связь и назначенная миссия каждого аппарата."),
|
||||||
|
contract("Очередь тревог", "Отклонения, потери связи и запросы оператора."),
|
||||||
|
later("Командные роли", "Разделение наблюдения, планирования и подтверждения действий."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "contour-health",
|
||||||
|
root: "center",
|
||||||
|
label: "Состояние контура",
|
||||||
|
title: "Состояние контура",
|
||||||
|
eyebrow: "ЦЕНТР / СОСТОЯНИЕ",
|
||||||
|
description: "Сенсоры, транспорт, вычисления и внешние сервисы в одном диагностическом дереве.",
|
||||||
|
icon: "shield",
|
||||||
|
kind: "catalog",
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
title: "Критический путь",
|
||||||
|
description: "От физического устройства до операторского интерфейса.",
|
||||||
|
capabilities: [
|
||||||
|
active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."),
|
||||||
|
active("Приём данных", "MQTT, декодирование облака точек и позы."),
|
||||||
|
ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."),
|
||||||
|
contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "operator-activity",
|
||||||
|
root: "center",
|
||||||
|
label: "Активность",
|
||||||
|
title: "Активность оператора",
|
||||||
|
eyebrow: "ЦЕНТР / АКТИВНОСТЬ",
|
||||||
|
description: "Хронология подключений, запусков, остановок и изменений конфигурации.",
|
||||||
|
icon: "list",
|
||||||
|
kind: "timeline",
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
title: "События",
|
||||||
|
description: "Операции интерфейса и ответы локального контура.",
|
||||||
|
capabilities: [
|
||||||
|
active("Текущие статусы", "REST и WebSocket уже передают фазу и текст операции."),
|
||||||
|
contract("Журнал действий", "Нормализованные события с пользователем, временем и результатом."),
|
||||||
|
later("Подпись подтверждений", "Аудит опасных команд и изменение полномочий."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vehicles",
|
||||||
|
root: "fleet",
|
||||||
|
label: "Аппараты",
|
||||||
|
title: "Реестр аппаратов",
|
||||||
|
eyebrow: "ПАРК / РЕЕСТР",
|
||||||
|
description: "Нейтральный реестр наземных, воздушных и стационарных платформ.",
|
||||||
|
icon: "apps",
|
||||||
|
kind: "catalog",
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
title: "Идентичность аппарата",
|
||||||
|
description: "Никакой привязки продуктовой модели к конкретному производителю.",
|
||||||
|
capabilities: [
|
||||||
|
ready("Локальный стенд", "Первый аппарат представлен текущим устройством и его адаптером."),
|
||||||
|
contract("Паспорт борта", "Тип, серийный профиль, вычислитель, питание и транспорт."),
|
||||||
|
contract("Состояние доступности", "Онлайн, занят, обслуживание, потеря связи."),
|
||||||
|
later("Группы и рои", "Логические группы, роли и совместное назначение миссий."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "local-device",
|
||||||
|
root: "fleet",
|
||||||
|
label: "Локальное устройство",
|
||||||
|
title: "Локальное устройство",
|
||||||
|
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
||||||
|
description: "Рабочий путь BLE → 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("Метрики потока в реальном времени", "Задержка, частота кадров, число точек и пропуски предпросмотра."),
|
||||||
|
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: "Следующий этап",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
export type SceneProjection = "3d" | "2d" | "map";
|
||||||
|
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
||||||
|
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||||
|
|
||||||
|
export interface SceneSettings {
|
||||||
|
projection: SceneProjection;
|
||||||
|
pointSize: number;
|
||||||
|
colorMode: PointColorMode;
|
||||||
|
palette: PointPalette;
|
||||||
|
customColor: string;
|
||||||
|
accumulationSeconds: number;
|
||||||
|
showPoints: boolean;
|
||||||
|
showTrajectory: boolean;
|
||||||
|
showGrid: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
showCameraFrustums: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSceneSettings: SceneSettings = {
|
||||||
|
projection: "3d",
|
||||||
|
pointSize: 2.5,
|
||||||
|
colorMode: "intensity",
|
||||||
|
palette: "turbo",
|
||||||
|
customColor: "#35d7c1",
|
||||||
|
accumulationSeconds: 12,
|
||||||
|
showPoints: true,
|
||||||
|
showTrajectory: true,
|
||||||
|
showGrid: true,
|
||||||
|
showLabels: false,
|
||||||
|
showCameraFrustums: true,
|
||||||
|
};
|
||||||
|
|
@ -1,970 +1,6 @@
|
||||||
:root {
|
@import "./styles/base.css";
|
||||||
background: #050506;
|
@import "./styles/shell.css";
|
||||||
}
|
@import "./styles/workspaces.css";
|
||||||
|
@import "./styles/spatial.css";
|
||||||
html {
|
@import "./styles/device.css";
|
||||||
min-width: 320px;
|
@import "./styles/responsive.css";
|
||||||
min-height: 100%;
|
|
||||||
background: #050506;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
background: #050506;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.k1-console {
|
|
||||||
--k1-panel: #151517;
|
|
||||||
--k1-panel-soft: #0d0d0f;
|
|
||||||
--k1-hairline: rgba(255, 255, 255, 0.08);
|
|
||||||
--k1-accent-soft: rgb(var(--nodedc-accent-rgb) / 0.11);
|
|
||||||
--k1-success-soft: rgb(var(--nodedc-success-rgb) / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-lockup {
|
|
||||||
display: inline-flex;
|
|
||||||
height: 100%;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 1.32rem;
|
|
||||||
font-weight: 850;
|
|
||||||
letter-spacing: -0.075em;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-lockup span {
|
|
||||||
color: rgb(var(--nodedc-accent-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-label {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.13em;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-dot {
|
|
||||||
width: 0.48rem;
|
|
||||||
height: 0.48rem;
|
|
||||||
flex: 0 0 0.48rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--nodedc-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-dot[data-status="online"] {
|
|
||||||
background: rgb(var(--nodedc-success-rgb));
|
|
||||||
box-shadow: 0 0 0 0.28rem rgb(var(--nodedc-success-rgb) / 0.09);
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-dot[data-status="checking"],
|
|
||||||
.api-dot[data-status="degraded"] {
|
|
||||||
background: rgb(var(--nodedc-warning-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-dot[data-status="offline"] {
|
|
||||||
background: rgb(var(--nodedc-danger-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-stage {
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
scrollbar-color: var(--nodedc-scrollbar-thumb) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-canvas {
|
|
||||||
position: relative;
|
|
||||||
width: min(100%, 112rem);
|
|
||||||
min-height: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 0 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-canvas::before {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 0;
|
|
||||||
top: -8rem;
|
|
||||||
right: -8rem;
|
|
||||||
width: 34rem;
|
|
||||||
height: 34rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 66%);
|
|
||||||
content: "";
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-canvas > * {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.9rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
border-radius: 1.1rem;
|
|
||||||
background: rgb(var(--nodedc-danger-rgb) / 0.1);
|
|
||||||
color: color-mix(in srgb, rgb(var(--nodedc-danger-rgb)) 82%, white);
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner > svg {
|
|
||||||
align-self: start;
|
|
||||||
margin-top: 0.12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner strong,
|
|
||||||
.error-banner p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner p {
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hero {
|
|
||||||
display: flex;
|
|
||||||
min-height: 15.5rem;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 3rem;
|
|
||||||
overflow: hidden;
|
|
||||||
scroll-margin-top: 1rem;
|
|
||||||
border-radius: var(--nodedc-radius-card);
|
|
||||||
background:
|
|
||||||
linear-gradient(112deg, rgba(255, 255, 255, 0.048), transparent 44%),
|
|
||||||
radial-gradient(circle at 79% 16%, rgb(var(--nodedc-accent-rgb) / 0.12), transparent 34%),
|
|
||||||
#0b0b0d;
|
|
||||||
padding: clamp(1.6rem, 3vw, 3rem);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045);
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hero__copy {
|
|
||||||
max-width: 46rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-eyebrow,
|
|
||||||
.metric-card__eyebrow {
|
|
||||||
display: block;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-weight: 820;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hero h1 {
|
|
||||||
max-width: 42rem;
|
|
||||||
margin: 0.65rem 0 0.9rem;
|
|
||||||
font-size: clamp(2rem, 4vw, 4.2rem);
|
|
||||||
font-weight: 710;
|
|
||||||
letter-spacing: -0.055em;
|
|
||||||
line-height: 0.98;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hero__copy p {
|
|
||||||
max-width: 39rem;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
font-size: 0.86rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hero__status {
|
|
||||||
display: grid;
|
|
||||||
max-width: 21rem;
|
|
||||||
justify-items: end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-path {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
||||||
gap: 0.7rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-path > div {
|
|
||||||
display: grid;
|
|
||||||
min-height: 6.8rem;
|
|
||||||
align-content: start;
|
|
||||||
gap: 0.42rem;
|
|
||||||
border: 1px solid var(--k1-hairline);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: var(--k1-panel-soft);
|
|
||||||
padding: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-path span {
|
|
||||||
color: rgb(var(--nodedc-accent-rgb));
|
|
||||||
font-size: 0.58rem;
|
|
||||||
font-weight: 850;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-path strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-path small {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.63rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
min-height: 9.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card[data-featured="true"] {
|
|
||||||
background: linear-gradient(145deg, var(--k1-accent-soft), transparent 72%), var(--k1-panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card__reading {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin-top: 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card__reading strong {
|
|
||||||
font-size: clamp(1.75rem, 2.6vw, 2.7rem);
|
|
||||||
font-weight: 690;
|
|
||||||
letter-spacing: -0.055em;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card__reading span {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card p {
|
|
||||||
margin: 0.9rem 0 0;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(21rem, 28.5rem) minmax(0, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
gap: 1.25rem;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-panel,
|
|
||||||
.session-panel {
|
|
||||||
scroll-margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-panel {
|
|
||||||
background: var(--k1-panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-heading h2 {
|
|
||||||
margin: 0.42rem 0 0;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 730;
|
|
||||||
letter-spacing: -0.035em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-heading--compact {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-list {
|
|
||||||
display: grid;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2.25rem minmax(0, 1fr);
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step__rail {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step__rail::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 2rem;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
width: 1px;
|
|
||||||
background: var(--k1-hairline);
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step:last-child .wizard-step__rail::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step__rail span {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: grid;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #27272a;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step__content {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.1rem 0 1.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step:last-child .wizard-step__content {
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step__content > header {
|
|
||||||
display: flex;
|
|
||||||
min-height: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step__content h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.86rem;
|
|
||||||
font-weight: 720;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-copy,
|
|
||||||
.safety-note {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.69rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.safety-note {
|
|
||||||
margin: 0.7rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-list {
|
|
||||||
display: grid;
|
|
||||||
max-height: 28rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.65rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: rgba(255, 255, 255, 0.035);
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row[data-selected="true"] {
|
|
||||||
background: var(--k1-accent-soft);
|
|
||||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row__identity,
|
|
||||||
.device-row__action {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row__identity {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row__identity > div {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row__name {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row__name small {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--k1-accent-soft);
|
|
||||||
color: rgb(var(--nodedc-accent-rgb));
|
|
||||||
padding: 0.2rem 0.42rem;
|
|
||||||
font-size: 0.5rem;
|
|
||||||
font-weight: 750;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row__identity strong {
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row code,
|
|
||||||
.detail-row code {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row__signal {
|
|
||||||
width: 0.6rem;
|
|
||||||
height: 0.6rem;
|
|
||||||
flex: 0 0 0.6rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nodedc-text-muted);
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.035);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row[data-likely-k1="true"] .device-row__signal {
|
|
||||||
background: rgb(var(--nodedc-success-rgb));
|
|
||||||
box-shadow: 0 0 0 0.25rem var(--k1-success-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-row__action > span {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.66rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-device-list {
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: rgba(255, 255, 255, 0.025);
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
padding: 0.95rem;
|
|
||||||
font-size: 0.69rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
padding: 0.65rem 0.8rem;
|
|
||||||
font-size: 0.69rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-summary span {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-summary strong {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-column {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel {
|
|
||||||
position: relative;
|
|
||||||
order: 2;
|
|
||||||
display: grid;
|
|
||||||
min-height: 20rem;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: end;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #0b0b0e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel__grid {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0.36;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px);
|
|
||||||
background-position: center;
|
|
||||||
background-size: 3rem 3rem;
|
|
||||||
mask-image: radial-gradient(circle at 58% 48%, #000 0%, transparent 66%);
|
|
||||||
transform: perspective(32rem) rotateX(56deg) scale(1.42) translateY(18%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 16%;
|
|
||||||
right: 14%;
|
|
||||||
width: 13rem;
|
|
||||||
height: 13rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 68%);
|
|
||||||
content: "";
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel__content,
|
|
||||||
.foxglove-panel__action {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel__content {
|
|
||||||
max-width: 37rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel h2 {
|
|
||||||
margin: 0.5rem 0 0.7rem;
|
|
||||||
font-size: clamp(1.6rem, 3vw, 3rem);
|
|
||||||
font-weight: 690;
|
|
||||||
letter-spacing: -0.05em;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel p {
|
|
||||||
max-width: 33rem;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topic-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topic-list code {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.055);
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
padding: 0.42rem 0.7rem;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel__action {
|
|
||||||
display: grid;
|
|
||||||
justify-items: end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-grid {
|
|
||||||
display: grid;
|
|
||||||
order: 3;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-panel,
|
|
||||||
.latency-panel,
|
|
||||||
.session-panel {
|
|
||||||
background: var(--k1-panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-panel {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0;
|
|
||||||
margin: 1rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-row {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
|
|
||||||
gap: 1rem;
|
|
||||||
border-top: 1px solid var(--k1-hairline);
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-row dt,
|
|
||||||
.detail-row dd {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-row dt {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-row dd {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
text-align: right;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-transform: capitalize;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-state {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-state::before {
|
|
||||||
width: 0.42rem;
|
|
||||||
height: 0.42rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nodedc-text-muted);
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-state[data-state="open"]::before {
|
|
||||||
background: rgb(var(--nodedc-success-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-state[data-state="error"]::before,
|
|
||||||
.inline-state[data-state="closed"]::before {
|
|
||||||
background: rgb(var(--nodedc-danger-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.latency-now {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 680;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latency-now span {
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.66rem;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latency-trace {
|
|
||||||
display: flex;
|
|
||||||
height: 8.4rem;
|
|
||||||
align-items: end;
|
|
||||||
gap: 0.22rem;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
background:
|
|
||||||
linear-gradient(to top, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
|
||||||
rgba(255, 255, 255, 0.018);
|
|
||||||
background-size: 100% 25%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latency-trace span {
|
|
||||||
min-width: 0.18rem;
|
|
||||||
flex: 1 1 0;
|
|
||||||
border-radius: 999px 999px 0.14rem 0.14rem;
|
|
||||||
background: linear-gradient(to top, rgb(var(--nodedc-accent-rgb) / 0.35), rgb(var(--nodedc-accent-rgb)));
|
|
||||||
}
|
|
||||||
|
|
||||||
.latency-trace p {
|
|
||||||
align-self: center;
|
|
||||||
margin: auto;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latency-legend {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.58rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-panel > .nodedc-segmented {
|
|
||||||
margin-top: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: end;
|
|
||||||
gap: 0.85rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-form--replay {
|
|
||||||
grid-template-columns: minmax(14rem, 1fr) minmax(8rem, 0.3fr) minmax(12rem, 0.5fr) auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-instruction {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
margin: 0;
|
|
||||||
border-left: 2px solid rgb(var(--nodedc-accent-rgb));
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
padding: 0.15rem 0 0.15rem 0.75rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
border-top: 1px solid var(--k1-hairline);
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-footer p {
|
|
||||||
max-width: 42rem;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1320px) {
|
|
||||||
.quick-path {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-layout {
|
|
||||||
grid-template-columns: minmax(20rem, 24rem) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-form--replay {
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.3fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-form--replay > :nth-child(3),
|
|
||||||
.session-form--replay > :nth-child(4) {
|
|
||||||
grid-column: span 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.console-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel {
|
|
||||||
min-height: 18rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.product-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-canvas {
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner {
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner__actions {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hero {
|
|
||||||
min-height: 20rem;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hero h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hero__status {
|
|
||||||
max-width: none;
|
|
||||||
justify-items: start;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics-grid {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.7rem;
|
|
||||||
margin-top: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-path {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.7rem;
|
|
||||||
margin-top: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
min-height: 8.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-layout,
|
|
||||||
.monitor-column,
|
|
||||||
.monitor-grid {
|
|
||||||
gap: 0.7rem;
|
|
||||||
margin-top: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foxglove-panel__action {
|
|
||||||
justify-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-form,
|
|
||||||
.session-form--replay {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-form--replay > :nth-child(3),
|
|
||||||
.session-form--replay > :nth-child(4) {
|
|
||||||
grid-column: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-footer {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.quick-path {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-heading {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step {
|
|
||||||
grid-template-columns: 1.8rem minmax(0, 1fr);
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step__rail span {
|
|
||||||
width: 1.65rem;
|
|
||||||
height: 1.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step__rail::after {
|
|
||||||
top: 1.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard-step__content > header {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-panel > .nodedc-segmented {
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
:root {
|
||||||
|
background: #050506;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #050506;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-station {
|
||||||
|
--station-panel: #151517;
|
||||||
|
--station-panel-soft: #0d0d0f;
|
||||||
|
--station-stage: #08090b;
|
||||||
|
--station-hairline: rgb(255 255 255 / 0.08);
|
||||||
|
--station-hairline-strong: rgb(255 255 255 / 0.13);
|
||||||
|
--station-accent-soft: rgb(var(--nodedc-accent-rgb) / 0.11);
|
||||||
|
--station-success-soft: rgb(var(--nodedc-success-rgb) / 0.1);
|
||||||
|
--station-warning-soft: rgb(var(--nodedc-warning-rgb) / 0.1);
|
||||||
|
--station-danger-soft: rgb(var(--nodedc-danger-rgb) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-station .nodedc-application-panel__body {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-color: var(--nodedc-scrollbar-thumb) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-eyebrow,
|
||||||
|
.metric-card__eyebrow {
|
||||||
|
display: block;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 820;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2,
|
||||||
|
.panel-heading h3 {
|
||||||
|
margin: 0.42rem 0 0;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 1.22rem;
|
||||||
|
font-weight: 730;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading--compact {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-lead {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-lead > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-lead h2 {
|
||||||
|
margin: 0.5rem 0 0.6rem;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: clamp(1.7rem, 3vw, 3rem);
|
||||||
|
font-weight: 690;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-lead p {
|
||||||
|
max-width: 50rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-lead__note {
|
||||||
|
max-width: 20rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
background: rgb(255 255 255 / 0.045);
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-lead__status {
|
||||||
|
display: grid;
|
||||||
|
max-width: 22rem;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.55rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
min-height: 7rem;
|
||||||
|
background: var(--station-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card[data-featured="true"] {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 90% 0%, rgb(var(--nodedc-accent-rgb) / 0.16), transparent 44%),
|
||||||
|
var(--station-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__reading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.36rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__reading strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: clamp(1.4rem, 2vw, 2.2rem);
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__reading span,
|
||||||
|
.metric-card p {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card p {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-stack,
|
||||||
|
.inspector-control-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contract-note {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.8rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(255 255 255 / 0.045);
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contract-note[data-tone="accent"] {
|
||||||
|
background: var(--station-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contract-note[data-tone="warning"] {
|
||||||
|
background: var(--station-warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contract-note[data-tone="success"] {
|
||||||
|
background: var(--station-success-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contract-note > svg {
|
||||||
|
margin-top: 0.12rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contract-note strong,
|
||||||
|
.modal-contract-note p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contract-note strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contract-note p {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-format-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-format-list > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(12rem, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
border-top: 1px solid var(--station-hairline);
|
||||||
|
padding: 0.7rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-format-list code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-format-list span {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.67rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-inspector-intro {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-inspector-intro p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.69rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-placeholder-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-placeholder-list > span {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-placeholder-list i {
|
||||||
|
width: 0.46rem;
|
||||||
|
height: 0.46rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-placeholder-list small {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
.device-workspace__grid {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(23rem, 0.78fr) minmax(34rem, 1.22fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-workspace__side {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-panel,
|
||||||
|
.status-panel,
|
||||||
|
.latency-panel,
|
||||||
|
.session-panel {
|
||||||
|
background: var(--station-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--station-danger-soft);
|
||||||
|
color: rgb(var(--nodedc-danger-rgb));
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner > svg {
|
||||||
|
align-self: start;
|
||||||
|
margin-top: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner strong,
|
||||||
|
.error-banner p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner p {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-list {
|
||||||
|
display: grid;
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.2rem minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step__rail {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step__rail::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 2rem;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--station-hairline);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step:last-child .wizard-step__rail::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step__rail span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #27272a;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.59rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step__content {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.08rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step:last-child .wizard-step__content {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step__content > header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step__content h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 710;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-copy,
|
||||||
|
.safety-note,
|
||||||
|
.live-instruction {
|
||||||
|
margin: 0 0 0.72rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.safety-note,
|
||||||
|
.live-instruction {
|
||||||
|
margin: 0.7rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
display: grid;
|
||||||
|
max-height: 20rem;
|
||||||
|
gap: 0.48rem;
|
||||||
|
margin-top: 0.72rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.62rem;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
background: rgb(255 255 255 / 0.035);
|
||||||
|
padding: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row[data-selected="true"] {
|
||||||
|
background: var(--station-accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row__identity,
|
||||||
|
.device-row__action {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row__identity {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row__identity > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row__name {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row__name small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--station-accent-soft);
|
||||||
|
color: rgb(var(--nodedc-accent-rgb));
|
||||||
|
padding: 0.18rem 0.38rem;
|
||||||
|
font-size: 0.47rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row__identity strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.69rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row code,
|
||||||
|
.detail-row code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row__signal {
|
||||||
|
width: 0.58rem;
|
||||||
|
height: 0.58rem;
|
||||||
|
flex: 0 0 0.58rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
box-shadow: 0 0 0 0.24rem rgb(255 255 255 / 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row[data-compatible="true"] .device-row__signal {
|
||||||
|
background: rgb(var(--nodedc-success-rgb));
|
||||||
|
box-shadow: 0 0 0 0.24rem var(--station-success-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row__action > span {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-device-list {
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
background: rgb(255 255 255 / 0.025);
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
padding: 0.9rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-stack,
|
||||||
|
.session-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0.72rem 0;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: rgb(255 255 255 / 0.03);
|
||||||
|
padding: 0.62rem 0.75rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-summary span {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-summary strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-panel > .nodedc-segmented {
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-form {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-form--replay {
|
||||||
|
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-form--replay > :first-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-form--replay > .nodedc-button {
|
||||||
|
align-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-top: 1px solid var(--station-hairline);
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-footer p {
|
||||||
|
max-width: 30rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list {
|
||||||
|
display: grid;
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
|
||||||
|
gap: 1rem;
|
||||||
|
border-top: 1px solid var(--station-hairline);
|
||||||
|
padding: 0.7rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row dt,
|
||||||
|
.detail-row dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row dt {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row dd {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-state::before {
|
||||||
|
width: 0.4rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-state[data-state="open"]::before { background: rgb(var(--nodedc-success-rgb)); }
|
||||||
|
.inline-state[data-state="error"]::before,
|
||||||
|
.inline-state[data-state="closed"]::before { background: rgb(var(--nodedc-danger-rgb)); }
|
||||||
|
|
||||||
|
.latency-now {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 660;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-now span {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.61rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-trace {
|
||||||
|
display: flex;
|
||||||
|
height: 7rem;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(to top, rgb(255 255 255 / 0.035) 1px, transparent 1px),
|
||||||
|
rgb(255 255 255 / 0.018);
|
||||||
|
background-size: 100% 25%;
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-trace span {
|
||||||
|
min-width: 0.16rem;
|
||||||
|
flex: 1 1 0;
|
||||||
|
border-radius: 999px 999px 0.12rem 0.12rem;
|
||||||
|
background: linear-gradient(to top, rgb(var(--nodedc-accent-rgb) / 0.35), rgb(var(--nodedc-accent-rgb)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-trace p {
|
||||||
|
align-self: center;
|
||||||
|
margin: auto;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-legend {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 0.38rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
@media (max-width: 1480px) {
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-workspace__grid {
|
||||||
|
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__copy {
|
||||||
|
width: min(35rem, 54%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.device-workspace__grid,
|
||||||
|
.overview-grid,
|
||||||
|
.mission-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-strip {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-strip > svg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-inspector {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.45fr);
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-inspector > .section-eyebrow,
|
||||||
|
.map-inspector > h3 {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-inspector .layer-summary-list {
|
||||||
|
grid-row: 1 / 4;
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-inspector > .nodedc-button {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: minmax(20rem, 1fr) repeat(2, minmax(10rem, 0.55fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot[data-primary="true"] {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1040px) {
|
||||||
|
.feature-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-lead {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-lead__note,
|
||||||
|
.workspace-lead__status {
|
||||||
|
max-width: none;
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__status {
|
||||||
|
width: 20rem;
|
||||||
|
max-width: 36vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__copy h1 {
|
||||||
|
font-size: clamp(2.7rem, 7vw, 5.6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-metrics {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.station-label,
|
||||||
|
.control-station .nodedc-header__profile-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__copy {
|
||||||
|
top: 42%;
|
||||||
|
right: 1.2rem;
|
||||||
|
left: 1.2rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__copy h1 {
|
||||||
|
font-size: clamp(2.8rem, 14vw, 5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__field {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__orbit,
|
||||||
|
.landing-stage__sweep {
|
||||||
|
left: 74%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__status {
|
||||||
|
top: auto;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 3.3rem;
|
||||||
|
left: 1rem;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__footer {
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1.1rem;
|
||||||
|
left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__footer span:first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid,
|
||||||
|
.capability-summary,
|
||||||
|
.camera-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid {
|
||||||
|
grid-template-rows: repeat(4, minmax(14rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot[data-primary="true"] {
|
||||||
|
grid-row: auto;
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-workspace {
|
||||||
|
min-height: 38rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-toolbar,
|
||||||
|
.spatial-toolbar__actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-toolbar__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-toolbar__actions .nodedc-button {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status--top-left {
|
||||||
|
top: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-adapter-note {
|
||||||
|
right: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
width: auto;
|
||||||
|
transform: translateY(4.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline > .nodedc-button:first-child,
|
||||||
|
.scene-timeline > code {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-inspector {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ruler {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-lane {
|
||||||
|
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-lane small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-form--replay {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-form--replay > :first-child {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-footer,
|
||||||
|
.error-banner,
|
||||||
|
.source-format-list > div {
|
||||||
|
align-items: stretch;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner > svg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner__actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row__action {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row > .nodedc-status-badge {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 720px) and (min-width: 761px) {
|
||||||
|
.landing-stage__copy h1 {
|
||||||
|
font-size: clamp(2.7rem, 6vw, 5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__copy p {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__status {
|
||||||
|
top: 1.2rem;
|
||||||
|
right: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
.station-label {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.61rem;
|
||||||
|
font-weight: 820;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-dot {
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
flex: 0 0 0.48rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-dot[data-status="online"] {
|
||||||
|
background: rgb(var(--nodedc-success-rgb));
|
||||||
|
box-shadow: 0 0 0 0.28rem rgb(var(--nodedc-success-rgb) / 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-dot[data-status="checking"],
|
||||||
|
.api-dot[data-status="degraded"] {
|
||||||
|
background: rgb(var(--nodedc-warning-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-dot[data-status="offline"] {
|
||||||
|
background: rgb(var(--nodedc-danger-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage {
|
||||||
|
--landing-accent: var(--nodedc-accent-rgb);
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--nodedc-radius-card);
|
||||||
|
background:
|
||||||
|
linear-gradient(115deg, rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5)),
|
||||||
|
var(--station-stage);
|
||||||
|
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage[data-root="center"] { --landing-accent: 84 177 255; }
|
||||||
|
.landing-stage[data-root="fleet"] { --landing-accent: 81 215 179; }
|
||||||
|
.landing-stage[data-root="observation"] { --landing-accent: 255 79 163; }
|
||||||
|
.landing-stage[data-root="missions"] { --landing-accent: 151 104 255; }
|
||||||
|
.landing-stage[data-root="data"] { --landing-accent: 93 192 255; }
|
||||||
|
.landing-stage[data-root="system"] { --landing-accent: 242 175 76; }
|
||||||
|
|
||||||
|
.landing-stage__field,
|
||||||
|
.landing-stage__shade,
|
||||||
|
.landing-stage__grid,
|
||||||
|
.landing-stage__orbit,
|
||||||
|
.landing-stage__sweep {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__field {
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 68% 42%, rgb(var(--landing-accent) / 0.1), transparent 24%),
|
||||||
|
radial-gradient(circle at 76% 48%, rgb(255 255 255 / 0.035), transparent 42%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__grid {
|
||||||
|
inset: -40% -25% -60% 25%;
|
||||||
|
opacity: 0.42;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgb(255 255 255 / 0.055) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(255 255 255 / 0.055) 1px, transparent 1px);
|
||||||
|
background-position: center;
|
||||||
|
background-size: 4.2rem 4.2rem;
|
||||||
|
mask-image: radial-gradient(circle at center, #000 0%, transparent 68%);
|
||||||
|
transform: perspective(42rem) rotateX(62deg) rotateZ(-8deg) scale(1.3) translateY(15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__orbit {
|
||||||
|
top: 50%;
|
||||||
|
left: 69%;
|
||||||
|
border: 1px solid rgb(var(--landing-accent) / 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__orbit--one {
|
||||||
|
width: min(22vw, 24rem);
|
||||||
|
height: min(22vw, 24rem);
|
||||||
|
animation: station-orbit 18s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__orbit--two {
|
||||||
|
width: min(34vw, 38rem);
|
||||||
|
height: min(34vw, 38rem);
|
||||||
|
border-color: rgb(var(--landing-accent) / 0.12);
|
||||||
|
animation: station-orbit-reverse 26s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__orbit--three {
|
||||||
|
width: min(49vw, 56rem);
|
||||||
|
height: min(49vw, 56rem);
|
||||||
|
border-color: rgb(255 255 255 / 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__orbit::before,
|
||||||
|
.landing-stage__orbit::after {
|
||||||
|
position: absolute;
|
||||||
|
width: 0.36rem;
|
||||||
|
height: 0.36rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(var(--landing-accent));
|
||||||
|
box-shadow: 0 0 1.1rem rgb(var(--landing-accent) / 0.6);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__orbit::before { top: -0.18rem; left: 50%; }
|
||||||
|
.landing-stage__orbit::after { right: 11%; bottom: 14%; opacity: 0.55; }
|
||||||
|
|
||||||
|
.landing-stage__sweep {
|
||||||
|
left: 69%;
|
||||||
|
width: min(32vw, 34rem);
|
||||||
|
height: min(32vw, 34rem);
|
||||||
|
margin: auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(from 220deg, transparent 0deg 318deg, rgb(var(--landing-accent) / 0.14) 344deg, transparent 360deg);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
animation: station-sweep 7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__node {
|
||||||
|
position: absolute;
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(var(--landing-accent));
|
||||||
|
box-shadow: 0 0 1.3rem rgb(var(--landing-accent) / 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__node--one { top: 29%; left: 62%; }
|
||||||
|
.landing-stage__node--two { top: 42%; left: 78%; animation: station-pulse 3.2s ease-in-out infinite; }
|
||||||
|
.landing-stage__node--three { top: 61%; left: 69%; animation: station-pulse 4.1s ease-in-out infinite 0.8s; }
|
||||||
|
.landing-stage__node--four { top: 52%; left: 87%; animation: station-pulse 3.6s ease-in-out infinite 1.4s; }
|
||||||
|
|
||||||
|
.landing-stage__shade {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgb(5 5 6 / 0.96) 0%, rgb(5 5 6 / 0.76) 34%, transparent 67%),
|
||||||
|
linear-gradient(0deg, rgb(5 5 6 / 0.86) 0%, transparent 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__copy {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 50%;
|
||||||
|
left: clamp(2rem, 5vw, 6rem);
|
||||||
|
width: min(39rem, 50%);
|
||||||
|
transform: translateY(-55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__copy h1 {
|
||||||
|
margin: 0.75rem 0 1rem;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: clamp(3rem, 7vw, 7.6rem);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.072em;
|
||||||
|
line-height: 0.87;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__copy p {
|
||||||
|
max-width: 34rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: clamp(0.8rem, 1vw, 1rem);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__status {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 2.4rem;
|
||||||
|
right: 2.4rem;
|
||||||
|
display: grid;
|
||||||
|
width: min(24rem, 30vw);
|
||||||
|
gap: 0.6rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.055);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: rgb(10 10 12 / 0.58);
|
||||||
|
padding: 1rem;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__status > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__status p {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-stage__footer {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
right: 2.4rem;
|
||||||
|
bottom: 1.8rem;
|
||||||
|
left: 2.4rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.57rem;
|
||||||
|
font-weight: 780;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes station-orbit {
|
||||||
|
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes station-orbit-reverse {
|
||||||
|
to { transform: translate(-50%, -50%) rotate(-360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes station-sweep {
|
||||||
|
to { transform: translateX(-50%) rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes station-pulse {
|
||||||
|
0%, 100% { opacity: 0.34; transform: scale(0.75); }
|
||||||
|
50% { opacity: 1; transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.landing-stage__orbit,
|
||||||
|
.landing-stage__sweep,
|
||||||
|
.landing-stage__node {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,347 @@
|
||||||
|
.spatial-workspace {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 34rem;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.65rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-toolbar {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-toolbar__mode,
|
||||||
|
.spatial-toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-viewport-shell {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #06070a;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport,
|
||||||
|
.rerun-viewport__canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__canvas canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
border: 1px solid var(--station-hairline);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(10 11 14 / 0.82);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice strong,
|
||||||
|
.rerun-viewport__notice span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice strong {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice span {
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.61rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__notice--error {
|
||||||
|
background: rgb(var(--nodedc-danger-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-indicator {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 1rem;
|
||||||
|
border: 2px solid rgb(255 255 255 / 0.12);
|
||||||
|
border-top-color: rgb(var(--nodedc-accent-rgb));
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: viewer-spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes viewer-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 68% 41%, rgb(var(--nodedc-accent-rgb) / 0.075), transparent 28%),
|
||||||
|
linear-gradient(125deg, #08090c, #040508);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage__grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: -25% -15% -85%;
|
||||||
|
opacity: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgb(255 255 255 / 0.07) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(255 255 255 / 0.07) 1px, transparent 1px);
|
||||||
|
background-position: center;
|
||||||
|
background-size: 3.7rem 3.7rem;
|
||||||
|
mask-image: radial-gradient(circle at center, #000 0%, transparent 70%);
|
||||||
|
transform: perspective(40rem) rotateX(66deg) rotateZ(-5deg) scale(1.45) translateY(8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage[data-grid="true"] .empty-spatial-stage__grid {
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-axis {
|
||||||
|
position: absolute;
|
||||||
|
right: 2rem;
|
||||||
|
bottom: 5.2rem;
|
||||||
|
width: 4.4rem;
|
||||||
|
height: 4.4rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.53rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-axis::before,
|
||||||
|
.spatial-axis::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.65rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
width: 2.7rem;
|
||||||
|
height: 1px;
|
||||||
|
background: rgb(255 255 255 / 0.2);
|
||||||
|
content: "";
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-axis::before { transform: rotate(-24deg); }
|
||||||
|
.spatial-axis::after { transform: rotate(-90deg); }
|
||||||
|
.spatial-axis span { position: absolute; }
|
||||||
|
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: #ff6a7a; }
|
||||||
|
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: #75d98a; }
|
||||||
|
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: #67a9ff; }
|
||||||
|
|
||||||
|
.empty-spatial-stage__message {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: grid;
|
||||||
|
max-width: 25rem;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.62rem;
|
||||||
|
transform: translate(-50%, -58%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage__icon {
|
||||||
|
display: grid;
|
||||||
|
width: 3.4rem;
|
||||||
|
height: 3.4rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--station-hairline-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 0.035);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage__message strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spatial-stage__message p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.67rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status,
|
||||||
|
.scene-metrics,
|
||||||
|
.scene-adapter-note,
|
||||||
|
.scene-selection,
|
||||||
|
.scene-timeline {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.055);
|
||||||
|
background: rgb(9 10 13 / 0.74);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status--top-left {
|
||||||
|
top: 0.85rem;
|
||||||
|
left: 0.85rem;
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 0.42rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-status small {
|
||||||
|
max-width: 18rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.57rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-metrics {
|
||||||
|
top: 0.85rem;
|
||||||
|
right: 0.85rem;
|
||||||
|
display: grid;
|
||||||
|
min-width: 10rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-metrics > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
border-bottom: 1px solid var(--station-hairline);
|
||||||
|
padding: 0.38rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-metrics > div:last-child { border-bottom: 0; }
|
||||||
|
.scene-metrics span { color: var(--nodedc-text-muted); font-size: 0.56rem; }
|
||||||
|
.scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; }
|
||||||
|
.scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; }
|
||||||
|
|
||||||
|
.scene-adapter-note {
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
max-width: 34rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
font-size: 0.63rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
transform: translate(-50%, 5.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-adapter-note > svg { flex: 0 0 auto; color: rgb(var(--nodedc-warning-rgb)); }
|
||||||
|
|
||||||
|
.scene-selection {
|
||||||
|
right: 0.85rem;
|
||||||
|
bottom: 4.6rem;
|
||||||
|
display: grid;
|
||||||
|
max-width: 19rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-selection span,
|
||||||
|
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
|
||||||
|
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.scene-timeline {
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__track {
|
||||||
|
height: 0.3rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__track span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: rgb(var(--nodedc-accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
|
||||||
|
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
|
||||||
|
|
||||||
|
.scene-timeline__follow {
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
background: rgb(255 255 255 / 0.055);
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
font-size: 0.52rem;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-timeline__follow[data-active="true"] {
|
||||||
|
background: var(--station-success-soft);
|
||||||
|
color: rgb(var(--nodedc-success-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-contract-strip {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem 1rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.57rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-contract-strip span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-contract-strip i {
|
||||||
|
width: 0.42rem;
|
||||||
|
height: 0.42rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spatial-contract-strip i[data-state="ready"] { background: rgb(var(--nodedc-accent-rgb)); }
|
||||||
|
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.busy-indicator { animation: none; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,628 @@
|
||||||
|
.standard-workspace,
|
||||||
|
.device-workspace {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-inventory {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-group {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.6fr);
|
||||||
|
gap: 1.6rem;
|
||||||
|
border-top: 1px solid var(--station-hairline);
|
||||||
|
padding-top: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-group > header h3 {
|
||||||
|
margin: 0.48rem 0 0.45rem;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
font-weight: 710;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-group > header p {
|
||||||
|
max-width: 25rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--station-hairline);
|
||||||
|
padding: 0.78rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row__mark {
|
||||||
|
width: 0.52rem;
|
||||||
|
height: 0.52rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row[data-status="active"] .feature-row__mark {
|
||||||
|
background: rgb(var(--nodedc-success-rgb));
|
||||||
|
box-shadow: 0 0 0 0.25rem var(--station-success-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row[data-status="ready"] .feature-row__mark {
|
||||||
|
background: rgb(var(--nodedc-accent-rgb));
|
||||||
|
box-shadow: 0 0 0 0.25rem var(--station-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row[data-status="contract"] .feature-row__mark {
|
||||||
|
background: rgb(var(--nodedc-warning-rgb));
|
||||||
|
box-shadow: 0 0 0 0.25rem var(--station-warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row strong,
|
||||||
|
.feature-row p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 690;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-row p {
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-summary > div {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4.4rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--station-panel);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-summary span {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-summary strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.55fr) minmax(19rem, 0.75fr);
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contour-panel,
|
||||||
|
.next-actions-panel,
|
||||||
|
.timeline-panel,
|
||||||
|
.mission-sequence,
|
||||||
|
.mission-summary,
|
||||||
|
.map-inspector {
|
||||||
|
background: var(--station-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-strip {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(7, auto);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-strip > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 6rem;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(255 255 255 / 0.034);
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-strip > div[data-state="ready"] {
|
||||||
|
background: var(--station-success-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-strip > div[data-state="contract"] {
|
||||||
|
background: var(--station-warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-strip > svg {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-strip span,
|
||||||
|
.pipeline-strip small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-strip strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.46rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list > button {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
outline: 0;
|
||||||
|
background: rgb(255 255 255 / 0.035);
|
||||||
|
padding: 0.65rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list > button:hover,
|
||||||
|
.action-list > button:focus-visible {
|
||||||
|
background: rgb(255 255 255 / 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list > button > span {
|
||||||
|
display: grid;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 0.055);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list strong,
|
||||||
|
.action-list small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list small {
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list > button > svg {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-grid {
|
||||||
|
display: grid;
|
||||||
|
min-height: 33rem;
|
||||||
|
grid-template-columns: minmax(0, 1.55fr) minmax(18rem, 0.72fr);
|
||||||
|
grid-template-rows: repeat(3, minmax(9rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #090a0c;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot[data-primary="true"] {
|
||||||
|
grid-row: 1 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.22;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px);
|
||||||
|
background-size: 3rem 3rem;
|
||||||
|
mask-image: radial-gradient(circle at center, #000, transparent 77%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot header,
|
||||||
|
.camera-slot footer,
|
||||||
|
.camera-slot__empty {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot header,
|
||||||
|
.camera-slot footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot header > span {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot__empty span {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot footer {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-slot footer button {
|
||||||
|
display: grid;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 0.04);
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-layout {
|
||||||
|
display: grid;
|
||||||
|
min-height: 34rem;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 18rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 30rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 72% 38%, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 28%),
|
||||||
|
#0a0b0d;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas__grid,
|
||||||
|
.map-canvas__contours {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas__grid {
|
||||||
|
opacity: 0.45;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgb(255 255 255 / 0.045) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(255 255 255 / 0.045) 1px, transparent 1px);
|
||||||
|
background-size: 2.5rem 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas__contours span {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.055);
|
||||||
|
border-radius: 48% 52% 62% 38%;
|
||||||
|
transform: rotate(-12deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas__contours span:nth-child(1) { inset: 16% 14% 20% 19%; }
|
||||||
|
.map-canvas__contours span:nth-child(2) { inset: 23% 21% 27% 27%; }
|
||||||
|
.map-canvas__contours span:nth-child(3) { inset: 31% 29% 35% 34%; }
|
||||||
|
.map-canvas__contours span:nth-child(4) { inset: 39% 37% 43% 42%; }
|
||||||
|
|
||||||
|
.map-canvas svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas svg path {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(var(--nodedc-accent-rgb));
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 3;
|
||||||
|
filter: drop-shadow(0 0 8px rgb(var(--nodedc-accent-rgb) / 0.34));
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas svg circle {
|
||||||
|
fill: #0a0b0d;
|
||||||
|
stroke: rgb(var(--nodedc-accent-rgb));
|
||||||
|
stroke-width: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-point-label {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
background: rgb(8 9 11 / 0.86);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
padding: 0.34rem 0.55rem;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-canvas__notice {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-inspector h3 {
|
||||||
|
margin: 0.52rem 0 1rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-summary-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.42rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-summary-list > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.15rem 0.55rem;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: rgb(255 255 255 / 0.035);
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-summary-list > div > span {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
align-self: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-summary-list > div > span[data-tone="cyan"] { background: #35d7c1; }
|
||||||
|
.layer-summary-list > div > span[data-tone="violet"] { background: #9a72ff; }
|
||||||
|
.layer-summary-list > div > span[data-tone="green"] { background: #a6dc74; }
|
||||||
|
.layer-summary-list > div > span[data-tone="orange"] { background: #f2af4c; }
|
||||||
|
|
||||||
|
.layer-summary-list strong { font-size: 0.68rem; }
|
||||||
|
.layer-summary-list small { color: var(--nodedc-text-muted); font-size: 0.58rem; }
|
||||||
|
|
||||||
|
.timeline-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-panel__head h3 {
|
||||||
|
margin: 0.48rem 0 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-panel__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-panel__controls code {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ruler {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(11, minmax(0, 1fr));
|
||||||
|
margin: 1.4rem 0 0.55rem 8.5rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ruler span {
|
||||||
|
border-left: 1px solid var(--station-hairline);
|
||||||
|
padding-left: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-lanes {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-lane {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: 7.5rem minmax(0, 1fr) 10rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
border-top: 1px solid var(--station-hairline);
|
||||||
|
padding: 0.82rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-lane strong,
|
||||||
|
.timeline-lane small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-lane strong { font-size: 0.67rem; }
|
||||||
|
.timeline-lane small { color: var(--nodedc-text-muted); font-size: 0.59rem; text-align: right; }
|
||||||
|
|
||||||
|
.timeline-lane > div {
|
||||||
|
position: relative;
|
||||||
|
height: 0.28rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-lane > div > span {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
display: none;
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(var(--nodedc-accent-rgb));
|
||||||
|
box-shadow: 0 0 0 0.26rem var(--station-accent-soft);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-lane[data-active="true"] > div > span { display: block; }
|
||||||
|
|
||||||
|
.mission-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(19rem, 0.65fr);
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-steps {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-steps button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(255 255 255 / 0.035);
|
||||||
|
padding: 0.65rem;
|
||||||
|
text-align: left;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-steps button > span {
|
||||||
|
display: grid;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 0.05);
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-steps strong,
|
||||||
|
.mission-steps small { display: block; }
|
||||||
|
.mission-steps strong { color: var(--nodedc-text-primary); font-size: 0.7rem; }
|
||||||
|
.mission-steps small { margin-top: 0.18rem; color: var(--nodedc-text-muted); font-size: 0.6rem; }
|
||||||
|
.mission-steps svg { color: var(--nodedc-text-muted); }
|
||||||
|
|
||||||
|
.mission-summary {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-readiness {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-readiness strong {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 580;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-readiness span,
|
||||||
|
.mission-summary p {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-summary p {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-summary__checks {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-summary__checks span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.67rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-summary__checks i {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
@ -10,17 +10,19 @@ import {
|
||||||
type LiveRequest,
|
type LiveRequest,
|
||||||
type ReplayRequest,
|
type ReplayRequest,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
import { localizeRuntimeMessage } from "./messages";
|
||||||
|
|
||||||
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
||||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop";
|
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop";
|
||||||
|
|
||||||
function messageFor(error: unknown): string {
|
function messageFor(error: unknown): string {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
|
const message = localizeRuntimeMessage(error.message) ?? error.message;
|
||||||
return error.status
|
return error.status
|
||||||
? `${error.message} (HTTP ${error.status})`
|
? `${message} (HTTP ${error.status})`
|
||||||
: error.message;
|
: message;
|
||||||
}
|
}
|
||||||
return "Запрос к локальному API K1 завершился ошибкой.";
|
return "Запрос к локальному сервису устройства завершился ошибкой.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function measuredLatency(state: ConsoleState | null): number | null {
|
function measuredLatency(state: ConsoleState | null): number | null {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,541 @@
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checker,
|
||||||
|
GlassSurface,
|
||||||
|
Icon,
|
||||||
|
SegmentedControl,
|
||||||
|
StatusBadge,
|
||||||
|
TextField,
|
||||||
|
type StatusTone,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type { BleDevice } from "../api";
|
||||||
|
import { MetricCard } from "../components/MetricCard";
|
||||||
|
import { localizeRuntimeMessage } from "../messages";
|
||||||
|
import {
|
||||||
|
backendLabel,
|
||||||
|
backendTone,
|
||||||
|
eventStatusLabel,
|
||||||
|
finiteMetric,
|
||||||
|
formatNumber,
|
||||||
|
phaseLabel,
|
||||||
|
phaseTone,
|
||||||
|
pipelineLatency,
|
||||||
|
sourceModeLabel,
|
||||||
|
} from "../presentation";
|
||||||
|
import type { useK1Console } from "../useK1Console";
|
||||||
|
|
||||||
|
type ConsoleController = ReturnType<typeof useK1Console>;
|
||||||
|
type SessionIntent = "live" | "replay";
|
||||||
|
|
||||||
|
const sessionItems = [
|
||||||
|
{ value: "live", label: "Реальное устройство" },
|
||||||
|
{ value: "replay", label: "Повтор записи" },
|
||||||
|
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||||
|
|
||||||
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="detail-row">
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WizardStep({
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
tone = "neutral",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
tone?: StatusTone;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="wizard-step">
|
||||||
|
<div className="wizard-step__rail" aria-hidden="true">
|
||||||
|
<span>{number}</span>
|
||||||
|
</div>
|
||||||
|
<div className="wizard-step__content">
|
||||||
|
<header>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<StatusBadge tone={tone}>{status}</StatusBadge>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceRow({
|
||||||
|
device,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
device: BleDevice;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="device-row"
|
||||||
|
data-compatible={device.likely_k1 ? "true" : undefined}
|
||||||
|
data-selected={selected ? "true" : undefined}
|
||||||
|
>
|
||||||
|
<div className="device-row__identity">
|
||||||
|
<span className="device-row__signal" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<span className="device-row__name">
|
||||||
|
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
|
||||||
|
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
|
||||||
|
</span>
|
||||||
|
<code>{device.device_id}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="device-row__action">
|
||||||
|
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant={selected ? "accent" : "secondary"}
|
||||||
|
disabled={device.connectable === false}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
{selected ? "Выбрано" : "Выбрать"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LatencyTrace({ values }: { values: number[] }) {
|
||||||
|
const ceiling = Math.max(16, ...values);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="latency-trace" aria-label="Последние измерения задержки потока">
|
||||||
|
{values.length ? (
|
||||||
|
values.map((value, index) => (
|
||||||
|
<span
|
||||||
|
key={`${index}-${value}`}
|
||||||
|
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
|
||||||
|
title={`${value.toFixed(1)} мс`}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>Измерений пока нет. График появится после получения реальных данных.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
backendStatus,
|
||||||
|
eventStatus,
|
||||||
|
pendingAction,
|
||||||
|
error,
|
||||||
|
latencyHistory,
|
||||||
|
refresh,
|
||||||
|
clearError,
|
||||||
|
scan,
|
||||||
|
connect,
|
||||||
|
startLive,
|
||||||
|
startReplay,
|
||||||
|
stop,
|
||||||
|
} = console;
|
||||||
|
|
||||||
|
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||||
|
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||||
|
const [ssid, setSsid] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||||
|
const [liveHost, setLiveHost] = useState("");
|
||||||
|
const [replayPath, setReplayPath] = useState("");
|
||||||
|
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||||
|
const [replayLoop, setReplayLoop] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state?.selected_device_id) {
|
||||||
|
setSelectedDeviceId(state.selected_device_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
selectedDeviceId &&
|
||||||
|
state?.devices &&
|
||||||
|
!state.devices.some((device) => device.device_id === selectedDeviceId)
|
||||||
|
) {
|
||||||
|
setSelectedDeviceId("");
|
||||||
|
}
|
||||||
|
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||||
|
|
||||||
|
const metrics = state?.metrics;
|
||||||
|
const latency = pipelineLatency(metrics);
|
||||||
|
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||||
|
const points = finiteMetric(metrics?.point_count);
|
||||||
|
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
|
||||||
|
const devices = state?.devices ?? [];
|
||||||
|
const isBusy = pendingAction !== null;
|
||||||
|
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||||
|
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||||
|
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
|
||||||
|
const sourceLabel = sourceModeLabel(state?.source_mode);
|
||||||
|
|
||||||
|
const deviceSummary = useMemo(
|
||||||
|
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||||
|
[devices, selectedDeviceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitConnect = async () => {
|
||||||
|
if (!canConnect) return;
|
||||||
|
const succeeded = await connect({
|
||||||
|
device_id: selectedDeviceId,
|
||||||
|
ssid: ssid.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (succeeded) setPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitLive = () => {
|
||||||
|
const host = liveHost.trim();
|
||||||
|
void startLive(host ? { host } : {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitReplay = () => {
|
||||||
|
const speed = Number(replaySpeed);
|
||||||
|
void startReplay({
|
||||||
|
path: replayPath.trim(),
|
||||||
|
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
|
||||||
|
loop: replayLoop,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="device-workspace">
|
||||||
|
{error ? (
|
||||||
|
<aside className="error-banner" role="alert">
|
||||||
|
<Icon name="alert" size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>Локальная операция завершилась ошибкой</strong>
|
||||||
|
<p>{localizeRuntimeMessage(error)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="error-banner__actions">
|
||||||
|
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
<Button size="compact" variant="ghost" onClick={clearError}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="workspace-lead workspace-lead--compact">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
|
||||||
|
<h2>Подключение первого устройства</h2>
|
||||||
|
<p>
|
||||||
|
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
|
||||||
|
операторская модель от конкретного устройства не зависят.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-lead__status">
|
||||||
|
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||||
|
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
|
||||||
|
<MetricCard
|
||||||
|
featured
|
||||||
|
eyebrow="ЗАДЕРЖКА КОНТУРА"
|
||||||
|
value={formatNumber(latency)}
|
||||||
|
unit="мс"
|
||||||
|
detail="Приём MQTT → публикация предпросмотра"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
eyebrow="ЧАСТОТА КАДРОВ"
|
||||||
|
value={formatNumber(frameRate)}
|
||||||
|
unit="кадр/с"
|
||||||
|
detail="Последнее измерение адаптера"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
eyebrow="ТОЧЕК В КАДРЕ"
|
||||||
|
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||||
|
detail="Реальное число декодированных точек"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
|
||||||
|
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||||
|
detail="Исходные данные при этом сохраняются"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="device-workspace__grid">
|
||||||
|
<GlassSurface className="connection-panel" padding="lg">
|
||||||
|
<header className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 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="accent"
|
||||||
|
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="accent"
|
||||||
|
icon={<Icon name="activity" />}
|
||||||
|
disabled={isBusy || !liveTargetReady}
|
||||||
|
onClick={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="accent"
|
||||||
|
icon={<Icon name="video" />}
|
||||||
|
disabled={isBusy || replayPath.trim().length === 0}
|
||||||
|
onClick={submitReplay}
|
||||||
|
>
|
||||||
|
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="session-footer">
|
||||||
|
<p>Статус изменится только после ответа локального сервиса.</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
|
||||||
|
onClick={() => void stop()}
|
||||||
|
>
|
||||||
|
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
|
<div className="diagnostics-grid">
|
||||||
|
<GlassSurface className="status-panel" padding="lg">
|
||||||
|
<header className="panel-heading panel-heading--compact">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
|
||||||
|
<h2>Локальный контур</h2>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||||
|
</header>
|
||||||
|
<dl className="detail-list">
|
||||||
|
<DetailRow label="Канал событий">
|
||||||
|
<span className="inline-state" data-state={eventStatus}>
|
||||||
|
{eventStatusLabel(eventStatus)}
|
||||||
|
</span>
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Источник">{sourceLabel}</DetailRow>
|
||||||
|
<DetailRow label="Адрес устройства">
|
||||||
|
<code>{state?.k1_ip || "Не получен"}</code>
|
||||||
|
</DetailRow>
|
||||||
|
</dl>
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
|
<GlassSurface className="latency-panel" padding="lg">
|
||||||
|
<header className="panel-heading panel-heading--compact">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
|
||||||
|
<h2>Задержка потока</h2>
|
||||||
|
</div>
|
||||||
|
<strong className="latency-now">
|
||||||
|
{formatNumber(latency)} <span>мс</span>
|
||||||
|
</strong>
|
||||||
|
</header>
|
||||||
|
<LatencyTrace values={latencyHistory} />
|
||||||
|
<div className="latency-legend">
|
||||||
|
<span>Старые</span>
|
||||||
|
<span>Последние</span>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,587 @@
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
GlassSurface,
|
||||||
|
Icon,
|
||||||
|
SegmentedControl,
|
||||||
|
StatusBadge,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type { ConsoleState } from "../api";
|
||||||
|
import { MetricCard } from "../components/MetricCard";
|
||||||
|
import { localizeRuntimeMessage } from "../messages";
|
||||||
|
import {
|
||||||
|
RerunViewport,
|
||||||
|
type RerunSelection,
|
||||||
|
type RerunViewportStatus,
|
||||||
|
} from "../components/RerunViewport";
|
||||||
|
import {
|
||||||
|
capabilityStatusLabel,
|
||||||
|
type CapabilityStatus,
|
||||||
|
type WorkspaceDefinition,
|
||||||
|
} from "../productModel";
|
||||||
|
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||||
|
import type { SceneProjection, SceneSettings } from "../sceneSettings";
|
||||||
|
import type { BackendStatus } from "../useK1Console";
|
||||||
|
|
||||||
|
const projectionItems = [
|
||||||
|
{ value: "3d", label: "3D" },
|
||||||
|
{ value: "2d", label: "2D" },
|
||||||
|
{ value: "map", label: "Карта" },
|
||||||
|
] satisfies Array<{ value: SceneProjection; label: string }>;
|
||||||
|
|
||||||
|
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||||
|
if (status === "active") return "success";
|
||||||
|
if (status === "ready") return "accent";
|
||||||
|
if (status === "contract") return "warning";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||||
|
return (
|
||||||
|
<div className="feature-inventory">
|
||||||
|
{definition.groups.map((group) => (
|
||||||
|
<section className="feature-group" key={group.title}>
|
||||||
|
<header>
|
||||||
|
<span className="section-eyebrow">ФУНКЦИОНАЛЬНЫЙ БЛОК</span>
|
||||||
|
<h3>{group.title}</h3>
|
||||||
|
<p>{group.description}</p>
|
||||||
|
</header>
|
||||||
|
<div className="feature-list">
|
||||||
|
{group.capabilities.map((capability) => (
|
||||||
|
<div className="feature-row" key={capability.label} data-status={capability.status}>
|
||||||
|
<span className="feature-row__mark" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{capability.label}</strong>
|
||||||
|
<p>{capability.description}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={statusTone(capability.status)}>
|
||||||
|
{capabilityStatusLabel[capability.status]}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
||||||
|
return (
|
||||||
|
<section className="workspace-lead workspace-lead--compact">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">{definition.eyebrow}</span>
|
||||||
|
<h2>{definition.title}</h2>
|
||||||
|
<p>{definition.description}</p>
|
||||||
|
</div>
|
||||||
|
{note ? <span className="workspace-lead__note">{note}</span> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceNavigation {
|
||||||
|
openView: (viewId: string) => void;
|
||||||
|
openSource: () => void;
|
||||||
|
openDisplay: () => void;
|
||||||
|
openLayers: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceRendererProps {
|
||||||
|
definition: WorkspaceDefinition;
|
||||||
|
state: ConsoleState | null;
|
||||||
|
backendStatus: BackendStatus;
|
||||||
|
sourceUrl: string;
|
||||||
|
sceneSettings: SceneSettings;
|
||||||
|
onSceneSettingsChange: (settings: SceneSettings) => void;
|
||||||
|
navigation: WorkspaceNavigation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewWorkspace({
|
||||||
|
definition,
|
||||||
|
state,
|
||||||
|
backendStatus,
|
||||||
|
navigation,
|
||||||
|
}: WorkspaceRendererProps) {
|
||||||
|
const metrics = state?.metrics;
|
||||||
|
const latency = pipelineLatency(metrics);
|
||||||
|
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||||
|
const points = finiteMetric(metrics?.point_count);
|
||||||
|
const adapterOnline = backendStatus === "online";
|
||||||
|
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="standard-workspace overview-workspace">
|
||||||
|
<WorkspaceLead
|
||||||
|
definition={definition}
|
||||||
|
note="Числа появляются только из реального локального контура"
|
||||||
|
/>
|
||||||
|
<section className="metrics-grid" aria-label="Оперативные показатели">
|
||||||
|
<MetricCard
|
||||||
|
featured
|
||||||
|
eyebrow="ЗАДЕРЖКА"
|
||||||
|
value={formatNumber(latency)}
|
||||||
|
unit="мс"
|
||||||
|
detail="Локальный путь приёма и публикации"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
eyebrow="ЧАСТОТА"
|
||||||
|
value={formatNumber(frameRate)}
|
||||||
|
unit="кадр/с"
|
||||||
|
detail="Последнее измерение потока"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
eyebrow="ТОЧЕК В КАДРЕ"
|
||||||
|
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||||
|
detail="Без синтетического заполнения"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
eyebrow="РЕЖИМ"
|
||||||
|
value={sourceModeLabel(state?.source_mode)}
|
||||||
|
detail="Реальное время, повтор или ожидание"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="overview-grid">
|
||||||
|
<GlassSurface className="contour-panel" padding="lg">
|
||||||
|
<header className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">КРИТИЧЕСКИЙ ПУТЬ</span>
|
||||||
|
<h2>Контур наблюдения</h2>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={adapterOnline ? "success" : "danger"}>
|
||||||
|
{adapterOnline ? "Контур доступен" : "Нет связи"}
|
||||||
|
</StatusBadge>
|
||||||
|
</header>
|
||||||
|
<div className="pipeline-strip" aria-label="Путь данных">
|
||||||
|
<div data-state={state?.k1_ip ? "ready" : "idle"}>
|
||||||
|
<span>01</span>
|
||||||
|
<strong>Устройство</strong>
|
||||||
|
<small>{state?.k1_ip || "не назначено"}</small>
|
||||||
|
</div>
|
||||||
|
<Icon name="chevron-right" />
|
||||||
|
<div data-state={streamActive ? "ready" : "idle"}>
|
||||||
|
<span>02</span>
|
||||||
|
<strong>Адаптер</strong>
|
||||||
|
<small>{sourceModeLabel(state?.source_mode)}</small>
|
||||||
|
</div>
|
||||||
|
<Icon name="chevron-right" />
|
||||||
|
<div data-state="contract">
|
||||||
|
<span>03</span>
|
||||||
|
<strong>Rerun</strong>
|
||||||
|
<small>ожидает источник</small>
|
||||||
|
</div>
|
||||||
|
<Icon name="chevron-right" />
|
||||||
|
<div data-state="ready">
|
||||||
|
<span>04</span>
|
||||||
|
<strong>Оператор</strong>
|
||||||
|
<small>интерфейс готов</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
|
||||||
|
<GlassSurface className="next-actions-panel" padding="lg">
|
||||||
|
<header className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">БЫСТРЫЙ ПЕРЕХОД</span>
|
||||||
|
<h2>Продолжить работу</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="action-list">
|
||||||
|
<button type="button" onClick={() => navigation.openView("local-device")}>
|
||||||
|
<span><Icon name="network" /></span>
|
||||||
|
<div>
|
||||||
|
<strong>Подключить устройство</strong>
|
||||||
|
<small>Bluetooth, 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="y">Y</span>
|
||||||
|
<span data-axis="z">Z</span>
|
||||||
|
</div>
|
||||||
|
<div className="empty-spatial-stage__message">
|
||||||
|
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
|
||||||
|
<strong>Визуальный источник не назначен</strong>
|
||||||
|
<p>
|
||||||
|
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
|
||||||
|
открыть реальные данные.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpatialWorkspace({
|
||||||
|
state,
|
||||||
|
sourceUrl,
|
||||||
|
sceneSettings,
|
||||||
|
onSceneSettingsChange,
|
||||||
|
navigation,
|
||||||
|
}: WorkspaceRendererProps) {
|
||||||
|
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||||
|
const [viewerMessage, setViewerMessage] = useState("");
|
||||||
|
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||||
|
const metrics = state?.metrics;
|
||||||
|
const latency = pipelineLatency(metrics);
|
||||||
|
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||||
|
const points = finiteMetric(metrics?.point_count);
|
||||||
|
|
||||||
|
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||||
|
setViewerStatus(status);
|
||||||
|
setViewerMessage(message ?? "");
|
||||||
|
}, []);
|
||||||
|
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||||
|
|
||||||
|
const viewerStatusLabel = {
|
||||||
|
idle: "Источник не назначен",
|
||||||
|
loading: "Подключение",
|
||||||
|
ready: "Визуализатор готов",
|
||||||
|
error: "Ошибка источника",
|
||||||
|
}[viewerStatus];
|
||||||
|
|
||||||
|
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="spatial-workspace">
|
||||||
|
<div className="spatial-toolbar">
|
||||||
|
<div className="spatial-toolbar__mode">
|
||||||
|
<SegmentedControl
|
||||||
|
label="Проекция сцены"
|
||||||
|
value={sceneSettings.projection}
|
||||||
|
items={projectionItems}
|
||||||
|
onChange={(projection) => onSceneSettingsChange({ ...sceneSettings, projection })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="spatial-toolbar__actions">
|
||||||
|
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||||
|
Источник
|
||||||
|
</Button>
|
||||||
|
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
||||||
|
Слои
|
||||||
|
</Button>
|
||||||
|
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
|
||||||
|
Отображение
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="spatial-viewport-shell">
|
||||||
|
{sourceUrl.trim() ? (
|
||||||
|
<RerunViewport
|
||||||
|
sourceUrl={sourceUrl}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptySpatialStage settings={sceneSettings} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="scene-status scene-status--top-left">
|
||||||
|
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||||
|
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||||
|
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
|
||||||
|
<div>
|
||||||
|
<span>КАДР/С</span>
|
||||||
|
<strong>{formatNumber(frameRate)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Точек</span>
|
||||||
|
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Задержка</span>
|
||||||
|
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? (
|
||||||
|
<div className="scene-adapter-note">
|
||||||
|
<Icon name="alert" />
|
||||||
|
<span>
|
||||||
|
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||||
|
но Rerun-адаптер пока не назначен.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selection ? (
|
||||||
|
<div className="scene-selection">
|
||||||
|
<span>Выбрано</span>
|
||||||
|
<strong>{selection.entityPath}</strong>
|
||||||
|
{selection.viewName ? <small>{selection.viewName}</small> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="scene-timeline">
|
||||||
|
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
|
||||||
|
<Icon name="chevron-left" />
|
||||||
|
</Button>
|
||||||
|
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
|
||||||
|
Воспроизвести
|
||||||
|
</Button>
|
||||||
|
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
|
||||||
|
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
|
||||||
|
</div>
|
||||||
|
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
|
||||||
|
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
|
||||||
|
ЭФИР
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="spatial-contract-strip">
|
||||||
|
<span><i data-state="ready" />Облако точек</span>
|
||||||
|
<span><i data-state="ready" />Траектория</span>
|
||||||
|
<span><i data-state="contract" />Преобразования</span>
|
||||||
|
<span><i data-state="contract" />Камеры в 3D</span>
|
||||||
|
<span><i data-state="contract" />Объекты / маски</span>
|
||||||
|
<span><i data-state="contract" />Компоновка</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
|
||||||
|
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
|
||||||
|
return (
|
||||||
|
<div className="standard-workspace cameras-workspace">
|
||||||
|
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||||
|
<div className="camera-grid">
|
||||||
|
{slots.map((slot, index) => (
|
||||||
|
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
|
||||||
|
<div className="camera-slot__grid" aria-hidden="true" />
|
||||||
|
<header>
|
||||||
|
<span>{slot}</span>
|
||||||
|
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
|
||||||
|
</header>
|
||||||
|
<div className="camera-slot__empty">
|
||||||
|
<Icon name={index === 3 ? "image" : "video"} />
|
||||||
|
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
|
||||||
|
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FeatureInventory definition={definition} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapWorkspace({ definition }: WorkspaceRendererProps) {
|
||||||
|
const isMissionRoute = definition.id === "routes";
|
||||||
|
return (
|
||||||
|
<div className="standard-workspace map-workspace">
|
||||||
|
<WorkspaceLead
|
||||||
|
definition={definition}
|
||||||
|
note={isMissionRoute ? "Схема интерфейса — координаты и командный канал не подключены" : "Картографический модуль не подключён"}
|
||||||
|
/>
|
||||||
|
<div className="map-layout">
|
||||||
|
<div className="map-canvas" data-route={isMissionRoute ? "true" : undefined}>
|
||||||
|
<div className="map-canvas__grid" aria-hidden="true" />
|
||||||
|
<div className="map-canvas__contours" aria-hidden="true">
|
||||||
|
<span /><span /><span /><span />
|
||||||
|
</div>
|
||||||
|
<svg viewBox="0 0 1000 560" role="img" aria-label="Схематический черновик маршрута без географических координат">
|
||||||
|
<path d="M165 420 C 235 350, 272 375, 340 292 S 480 235, 545 290 S 680 340, 765 210 S 845 125, 892 155" />
|
||||||
|
<circle cx="165" cy="420" r="8" />
|
||||||
|
<circle cx="340" cy="292" r="8" />
|
||||||
|
<circle cx="545" cy="290" r="8" />
|
||||||
|
<circle cx="765" cy="210" r="8" />
|
||||||
|
<circle cx="892" cy="155" r="8" />
|
||||||
|
</svg>
|
||||||
|
<div className="map-point-label" style={{ left: "15%", top: "72%" }}>A · старт</div>
|
||||||
|
<div className="map-point-label" style={{ left: "52%", top: "49%" }}>C · наблюдение</div>
|
||||||
|
<div className="map-point-label" style={{ left: "84%", top: "22%" }}>E · финиш</div>
|
||||||
|
<div className="map-canvas__notice">
|
||||||
|
<StatusBadge tone="warning">Схема интерфейса</StatusBadge>
|
||||||
|
<span>Не является картой местности и не содержит реальных координат.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GlassSurface className="map-inspector" padding="lg">
|
||||||
|
<span className="section-eyebrow">СЛОИ КАРТЫ</span>
|
||||||
|
<h3>Рабочая композиция</h3>
|
||||||
|
<div className="layer-summary-list">
|
||||||
|
<div><span data-tone="cyan" /><strong>Маршрут</strong><small>5 точек · схема</small></div>
|
||||||
|
<div><span data-tone="violet" /><strong>Геозоны</strong><small>не назначены</small></div>
|
||||||
|
<div><span data-tone="green" /><strong>Высота</strong><small>контракт GridMap</small></div>
|
||||||
|
<div><span data-tone="orange" /><strong>Проходимость</strong><small>обработчик не подключён</small></div>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" width="full" disabled>Редактировать точки</Button>
|
||||||
|
</GlassSurface>
|
||||||
|
</div>
|
||||||
|
<FeatureInventory definition={definition} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
|
||||||
|
const rows = useMemo(() => [
|
||||||
|
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.source_mode) : "нет событий", active: Boolean(state?.phase) },
|
||||||
|
{ label: "Поток точек", value: state?.source_mode && state.source_mode !== "idle" ? "активен" : "ожидание", active: state?.source_mode !== "idle" && Boolean(state?.source_mode) },
|
||||||
|
{ label: "Поза", value: state?.metrics?.point_count ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.point_count) },
|
||||||
|
{ label: "Камеры", value: "каналы не назначены", active: false },
|
||||||
|
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) },
|
||||||
|
], [state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="standard-workspace timeline-workspace">
|
||||||
|
<WorkspaceLead definition={definition} note="Отображаются только доступные состояния" />
|
||||||
|
<GlassSurface className="timeline-panel" padding="lg">
|
||||||
|
<header className="timeline-panel__head">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">СИНХРОННЫЕ КАНАЛЫ</span>
|
||||||
|
<h3>Текущий интервал</h3>
|
||||||
|
</div>
|
||||||
|
<div className="timeline-panel__controls">
|
||||||
|
<Button size="compact" variant="secondary" disabled>Воспроизвести</Button>
|
||||||
|
<code>—:—:—.———</code>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="timeline-ruler" aria-hidden="true">
|
||||||
|
{Array.from({ length: 11 }, (_, index) => <span key={index}>{index * 10}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="timeline-lanes">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<div className="timeline-lane" key={row.label} data-active={row.active ? "true" : undefined}>
|
||||||
|
<strong>{row.label}</strong>
|
||||||
|
<div><span style={{ left: row.active ? `${18 + index * 9}%` : "0" }} /></div>
|
||||||
|
<small>{row.value}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
<FeatureInventory definition={definition} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
|
||||||
|
const steps = [
|
||||||
|
{ id: "01", label: "Аппарат", value: "Не назначен" },
|
||||||
|
{ id: "02", label: "Зона", value: "Не задана" },
|
||||||
|
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
|
||||||
|
{ id: "04", label: "Наблюдение", value: "Облако точек" },
|
||||||
|
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="standard-workspace mission-workspace">
|
||||||
|
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
|
||||||
|
<div className="mission-layout">
|
||||||
|
<GlassSurface className="mission-sequence" padding="lg">
|
||||||
|
<header className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
|
||||||
|
<h2>Новая миссия</h2>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||||
|
</header>
|
||||||
|
<div className="mission-steps">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<button key={step.id} type="button" disabled>
|
||||||
|
<span>{step.id}</span>
|
||||||
|
<div><strong>{step.label}</strong><small>{step.value}</small></div>
|
||||||
|
<Icon name="chevron-right" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
<GlassSurface className="mission-summary" padding="lg">
|
||||||
|
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
|
||||||
|
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
|
||||||
|
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
|
||||||
|
<div className="mission-summary__checks">
|
||||||
|
<span><i />Аппарат</span>
|
||||||
|
<span><i />Геометрия</span>
|
||||||
|
<span><i />Связь</span>
|
||||||
|
<span><i />Безопасность</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="accent" width="full" disabled>Сохранить миссию</Button>
|
||||||
|
</GlassSurface>
|
||||||
|
</div>
|
||||||
|
<FeatureInventory definition={definition} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
|
||||||
|
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
|
||||||
|
const active = definition.groups.reduce(
|
||||||
|
(count, group) => count + group.capabilities.filter((capability) => capability.status === "active").length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const ready = definition.groups.reduce(
|
||||||
|
(count, group) => count + group.capabilities.filter((capability) => capability.status === "ready").length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="standard-workspace catalog-workspace">
|
||||||
|
<WorkspaceLead definition={definition} note="Архитектурный и интерфейсный контракт" />
|
||||||
|
<section className="capability-summary" aria-label="Готовность функционала">
|
||||||
|
<div><span>Всего функций</span><strong>{total}</strong></div>
|
||||||
|
<div><span>Работает сейчас</span><strong>{active}</strong></div>
|
||||||
|
<div><span>Готово к данным</span><strong>{ready}</strong></div>
|
||||||
|
<div><span>Без серверной логики</span><strong>{Math.max(0, total - active - ready)}</strong></div>
|
||||||
|
</section>
|
||||||
|
<FeatureInventory definition={definition} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||||
|
switch (props.definition.kind) {
|
||||||
|
case "overview":
|
||||||
|
return <OverviewWorkspace {...props} />;
|
||||||
|
case "spatial":
|
||||||
|
return <SpatialWorkspace {...props} />;
|
||||||
|
case "cameras":
|
||||||
|
return <CamerasWorkspace {...props} />;
|
||||||
|
case "map":
|
||||||
|
return <MapWorkspace {...props} />;
|
||||||
|
case "timeline":
|
||||||
|
return <TimelineWorkspace {...props} />;
|
||||||
|
case "missions":
|
||||||
|
return <MissionWorkspace {...props} />;
|
||||||
|
case "catalog":
|
||||||
|
return <CatalogWorkspace {...props} />;
|
||||||
|
case "device":
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import wasm from "vite-plugin-wasm";
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), "");
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react()],
|
plugins: [react(), wasm()],
|
||||||
|
build: {
|
||||||
|
target: "esnext",
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class VisualizationRuntime:
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self._phase: RuntimePhase = "idle"
|
self._phase: RuntimePhase = "idle"
|
||||||
self._message = "Готово. Включите K1 и начните с поиска по Bluetooth."
|
self._message = "Готово. Включите устройство и начните с поиска по Bluetooth."
|
||||||
self._source_mode: SourceMode = "idle"
|
self._source_mode: SourceMode = "idle"
|
||||||
self._foxglove_ws_url: str | None = None
|
self._foxglove_ws_url: str | None = None
|
||||||
self._foxglove_viewer_url: str | None = None
|
self._foxglove_viewer_url: str | None = None
|
||||||
|
|
@ -65,7 +65,7 @@ class VisualizationRuntime:
|
||||||
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
|
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
|
||||||
resolved = path.expanduser().resolve()
|
resolved = path.expanduser().resolve()
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
raise ValueError("файл записи не найден на этом MacBook")
|
raise ValueError("файл записи не найден на этом компьютере")
|
||||||
if not math.isfinite(speed) or speed < 0:
|
if not math.isfinite(speed) or speed < 0:
|
||||||
raise ValueError("скорость повтора должна быть неотрицательным числом")
|
raise ValueError("скорость повтора должна быть неотрицательным числом")
|
||||||
# Validate the reviewed shape before changing runtime state.
|
# Validate the reviewed shape before changing runtime state.
|
||||||
|
|
@ -96,7 +96,7 @@ class VisualizationRuntime:
|
||||||
self._start(
|
self._start(
|
||||||
source_mode="live",
|
source_mode="live",
|
||||||
phase="starting_live",
|
phase="starting_live",
|
||||||
message="Запускаем приём MQTT и мост Foxglove.",
|
message="Запускаем приём MQTT и локальный мост визуализации.",
|
||||||
target=lambda: self._run_live(
|
target=lambda: self._run_live(
|
||||||
host,
|
host,
|
||||||
out_dir.expanduser().resolve(),
|
out_dir.expanduser().resolve(),
|
||||||
|
|
@ -199,7 +199,8 @@ class VisualizationRuntime:
|
||||||
out_dir / "captures" / "mqtt_live",
|
out_dir / "captures" / "mqtt_live",
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
on_ready=lambda: self._set_running(
|
on_ready=lambda: self._set_running(
|
||||||
"live", "Приём запущен. Теперь дважды нажмите физическую кнопку K1."
|
"live",
|
||||||
|
"Приём запущен. Теперь дважды нажмите физическую кнопку устройства.",
|
||||||
),
|
),
|
||||||
on_message_recorded=on_message,
|
on_message_recorded=on_message,
|
||||||
should_stop=self._stop_event.is_set,
|
should_stop=self._stop_event.is_set,
|
||||||
|
|
@ -248,7 +249,9 @@ class VisualizationRuntime:
|
||||||
self._foxglove_viewer_url = bridge.viewer_url
|
self._foxglove_viewer_url = bridge.viewer_url
|
||||||
if self._phase != "stopping":
|
if self._phase != "stopping":
|
||||||
self._phase = running_phase
|
self._phase = running_phase
|
||||||
self._message = "Мост Foxglove готов; источник данных запущен."
|
self._message = (
|
||||||
|
"Локальный мост визуализации готов; источник данных запущен."
|
||||||
|
)
|
||||||
publisher_ready.set()
|
publisher_ready.set()
|
||||||
self._notify()
|
self._notify()
|
||||||
while not source_done.is_set() or not messages.empty():
|
while not source_done.is_set() or not messages.empty():
|
||||||
|
|
@ -275,13 +278,14 @@ class VisualizationRuntime:
|
||||||
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True)
|
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True)
|
||||||
publisher.start()
|
publisher.start()
|
||||||
if not publisher_ready.wait(timeout=15.0):
|
if not publisher_ready.wait(timeout=15.0):
|
||||||
self._finish_error("Мост Foxglove не запустился за 15 секунд.")
|
self._finish_error("Локальный мост визуализации не запустился за 15 секунд.")
|
||||||
source_done.set()
|
source_done.set()
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
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()
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -80,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 = "K1 выбран. Теперь введите название и пароль Wi-Fi."
|
message = "Устройство выбрано. Теперь введите название и пароль Wi-Fi."
|
||||||
elif devices:
|
elif devices:
|
||||||
likely_count = sum(bool(item.get("likely_k1")) for item in devices)
|
likely_count = sum(bool(item.get("likely_k1")) for item in devices)
|
||||||
phase = "idle"
|
phase = "idle"
|
||||||
message = (
|
message = (
|
||||||
f"Найдено BLE-устройств: {len(devices)}. "
|
f"Найдено BLE-устройств: {len(devices)}. "
|
||||||
f"Возможных K1: {likely_count}. Выберите нужное устройство."
|
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
phase = "idle"
|
phase = "idle"
|
||||||
|
|
@ -144,7 +144,7 @@ class ConsoleService:
|
||||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||||
self._set_operation(
|
self._set_operation(
|
||||||
"provisioning",
|
"provisioning",
|
||||||
"Передаём в K1 настройки Wi-Fi одним подтверждённым запросом.",
|
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
|
||||||
)
|
)
|
||||||
session_dir = _new_operation_session_dir(
|
session_dir = _new_operation_session_dir(
|
||||||
self.repository_root,
|
self.repository_root,
|
||||||
|
|
@ -177,12 +177,14 @@ class ConsoleService:
|
||||||
)
|
)
|
||||||
if ipv4 is None:
|
if ipv4 is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"K1 не сообщил адрес в локальной сети; автоматического повтора не было"
|
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._selected_device_id = request.device_id
|
self._selected_device_id = request.device_id
|
||||||
self._k1_ip = ipv4
|
self._k1_ip = ipv4
|
||||||
self._operation_message = "K1 подключён к Wi-Fi и сообщил локальный адрес."
|
self._operation_message = (
|
||||||
|
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
password = ""
|
password = ""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -193,7 +195,7 @@ class ConsoleService:
|
||||||
target = host or self.state()["k1_ip"]
|
target = host or self.state()["k1_ip"]
|
||||||
if not isinstance(target, str) or not target:
|
if not isinstance(target, str) or not target:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"сначала подключите K1 к Wi-Fi или укажите его локальный адрес"
|
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
|
||||||
)
|
)
|
||||||
target = validate_private_ipv4(target)
|
target = validate_private_ipv4(target)
|
||||||
out_dir = new_live_session_dir(self.repository_root)
|
out_dir = new_live_session_dir(self.repository_root)
|
||||||
|
|
@ -219,7 +221,7 @@ class ConsoleService:
|
||||||
|
|
||||||
service = ConsoleService(REPOSITORY_ROOT)
|
service = ConsoleService(REPOSITORY_ROOT)
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="NODE.DC K1 Live Console API",
|
title="NODE.DC Device Control API",
|
||||||
version=__version__,
|
version=__version__,
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
|
|
@ -257,7 +259,7 @@ async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||||
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
|
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=502,
|
status_code=502,
|
||||||
detail=f"Ошибка подключения K1 к Wi-Fi: {exc}",
|
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue