From b1c52ab748e3935a956fbaa51f6c676d7fefab54 Mon Sep 17 00:00:00 2001 From: German Date: Tue, 7 Jul 2026 23:57:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=96=D0=B5=D0=BB=D0=B5=D0=B7=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=95=D0=B3=D0=BE=D1=80=D0=B8=D0=BD=20=E2=80=94=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BA=20=D0=9E=D1=80=D0=B1=D0=B8=D1=82=D1=8B?= =?UTF-8?q?:=20=D1=82=D1=91=D0=BF=D0=BB=D1=8B=D0=B9=20=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=20(amber),=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=20?= =?UTF-8?q?=D1=81=20odyssey=20WebGL-=D0=BC=D0=BE=D0=BB=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B0,=20=D1=8D=D0=BD?= =?UTF-8?q?=D0=B5=D1=80=D0=B3=D0=BE=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3+=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ребренд Мониторинг Орбита -> Железный Егорин; accent green->amber #F59E0B; LoginScreen правая половина = Lightning (WebGL, hue 35 тёплый) под стеклянной формой; левая панель — тёплый градиент. Доступ агентам: репо публичный, push german-токеном. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 3 + .gitignore | 12 + DEPLOY-mapviewgl-lilia.md | 38 + Dockerfile | 12 + README.md | 46 + SPEC.md | 64 + .../__pycache__/pcn-receiver.cpython-314.pyc | Bin 0 -> 27157 bytes deploy/ingest/docker-compose.yml | 51 + deploy/ingest/mosquitto/mosquitto.conf | 3 + deploy/ingress-watchdog.service | 9 + deploy/ingress-watchdog.sh | 153 + deploy/ingress-watchdog.timer | 11 + deploy/mqtt_subtest.py | 39 + deploy/n8n-fixerr.js | 9 + deploy/orbita-commands.workflow.json | 96 + deploy/pcn-receiver.py | 509 +++ deploy/pcn-receiver.service | 16 + deploy/pcn.b64 | 1 + deploy/ru2-mqtt_publisher.py | 296 ++ deploy/svc.b64 | 1 + docker-compose.yml | 10 + index.html | 18 + nginx.conf | 22 + package-lock.json | 3411 +++++++++++++++++ package.json | 37 + postcss.config.js | 6 + prototypes/orbit-gps-mobile.html | 633 +++ server/.env.example | 20 + server/.gitignore | 5 + server/Dockerfile | 7 + server/package.json | 18 + server/src/adapters/influx.js | 38 + server/src/adapters/mqtt.js | 50 + server/src/adapters/traccar.js | 34 + server/src/auth.js | 89 + server/src/config.js | 38 + server/src/demo.js | 77 + server/src/metrics.js | 78 + server/src/server.js | 197 + server/src/store.js | 77 + server/src/tenants.js | 180 + src/App.tsx | 61 + src/api/client.ts | 75 + src/components/ExportPdfButton.tsx | 17 + src/components/Lightning.tsx | 94 + src/components/LoginScreen.tsx | 195 + src/components/MapFrame.tsx | 26 + src/components/PageHeader.tsx | 28 + src/components/RadioMiniWindow.tsx | 70 + src/components/layout/AppShell.tsx | 240 ++ src/components/ui/Collapsible.tsx | 76 + src/components/ui/KpiCard.tsx | 60 + src/components/ui/primitives.tsx | 84 + src/config/modules.ts | 54 + src/core/auth.ts | 130 + src/core/notifyEngine.ts | 78 + src/core/radio.ts | 120 + src/core/store.ts | 52 + src/core/theme.ts | 39 + src/core/types.ts | 37 + src/data/alarmHandling.ts | 83 + src/data/geofences.ts | 84 + src/data/guardObjects.ts | 63 + src/data/locations.ts | 89 + src/data/mesh2.ts | 81 + src/data/mock.ts | 132 + src/data/notify.ts | 148 + src/data/repos.ts | 78 + src/data/roadEvents.ts | 73 + src/data/security.ts | 203 + src/data/trips.ts | 28 + src/index.css | 69 + src/lib/report.ts | 47 + src/lib/utils.ts | 17 + src/main.tsx | 13 + src/modules/access/AccessModule.tsx | 247 ++ src/modules/admin/AdminModule.tsx | 277 ++ src/modules/cabinet/CabinetModule.tsx | 165 + src/modules/devices/DevicesModule.tsx | 131 + src/modules/energy/EnergyModule.tsx | 224 ++ src/modules/geo/GeoModule.tsx | 151 + src/modules/gps/EventsModule.tsx | 95 + src/modules/gps/GpsModule.tsx | 556 +++ src/modules/gps/GpsTabs.tsx | 33 + src/modules/gps/MapSwitch.tsx | 17 + src/modules/gps/MapView.tsx | 270 ++ src/modules/gps/MapViewGL.tsx | 232 ++ src/modules/gps/ObjectDetails.tsx | 169 + src/modules/gps/ObjectEdit.tsx | 166 + src/modules/gps/ShareCoords.tsx | 49 + src/modules/gps/TrackPlayer.tsx | 179 + src/modules/gps/objectCustom.ts | 89 + src/modules/gps/useGpsData.ts | 83 + src/modules/network/NetworkModule.tsx | 181 + src/modules/notify/NotifyModule.tsx | 208 + src/modules/notify/NotifyTester.tsx | 121 + src/modules/repos/ReposModule.tsx | 113 + src/modules/security/AlarmHandling.tsx | 123 + src/modules/security/ArchiveModule.tsx | 124 + src/modules/security/GuardObjectEditor.tsx | 88 + src/modules/security/HourStrip.tsx | 53 + src/modules/security/ObjectsModule.tsx | 116 + src/modules/security/SecurityModule.tsx | 384 ++ src/modules/security/SecurityTabs.tsx | 33 + src/modules/smart/LocationCard.tsx | 59 + src/modules/smart/LocationsModule.tsx | 27 + src/modules/smart/SmartModule.tsx | 228 ++ src/modules/smart/SmartTabs.tsx | 32 + src/modules/smart/useSmartData.ts | 71 + src/vite-env.d.ts | 1 + tailwind.config.js | 41 + tsconfig.json | 23 + tsconfig.node.json | 11 + vite.config.ts | 12 + 114 files changed, 14340 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOY-mapviewgl-lilia.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 deploy/__pycache__/pcn-receiver.cpython-314.pyc create mode 100644 deploy/ingest/docker-compose.yml create mode 100644 deploy/ingest/mosquitto/mosquitto.conf create mode 100644 deploy/ingress-watchdog.service create mode 100644 deploy/ingress-watchdog.sh create mode 100644 deploy/ingress-watchdog.timer create mode 100644 deploy/mqtt_subtest.py create mode 100644 deploy/n8n-fixerr.js create mode 100644 deploy/orbita-commands.workflow.json create mode 100644 deploy/pcn-receiver.py create mode 100644 deploy/pcn-receiver.service create mode 100644 deploy/pcn.b64 create mode 100644 deploy/ru2-mqtt_publisher.py create mode 100644 deploy/svc.b64 create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prototypes/orbit-gps-mobile.html create mode 100644 server/.env.example create mode 100644 server/.gitignore create mode 100644 server/Dockerfile create mode 100644 server/package.json create mode 100644 server/src/adapters/influx.js create mode 100644 server/src/adapters/mqtt.js create mode 100644 server/src/adapters/traccar.js create mode 100644 server/src/auth.js create mode 100644 server/src/config.js create mode 100644 server/src/demo.js create mode 100644 server/src/metrics.js create mode 100644 server/src/server.js create mode 100644 server/src/store.js create mode 100644 server/src/tenants.js create mode 100644 src/App.tsx create mode 100644 src/api/client.ts create mode 100644 src/components/ExportPdfButton.tsx create mode 100644 src/components/Lightning.tsx create mode 100644 src/components/LoginScreen.tsx create mode 100644 src/components/MapFrame.tsx create mode 100644 src/components/PageHeader.tsx create mode 100644 src/components/RadioMiniWindow.tsx create mode 100644 src/components/layout/AppShell.tsx create mode 100644 src/components/ui/Collapsible.tsx create mode 100644 src/components/ui/KpiCard.tsx create mode 100644 src/components/ui/primitives.tsx create mode 100644 src/config/modules.ts create mode 100644 src/core/auth.ts create mode 100644 src/core/notifyEngine.ts create mode 100644 src/core/radio.ts create mode 100644 src/core/store.ts create mode 100644 src/core/theme.ts create mode 100644 src/core/types.ts create mode 100644 src/data/alarmHandling.ts create mode 100644 src/data/geofences.ts create mode 100644 src/data/guardObjects.ts create mode 100644 src/data/locations.ts create mode 100644 src/data/mesh2.ts create mode 100644 src/data/mock.ts create mode 100644 src/data/notify.ts create mode 100644 src/data/repos.ts create mode 100644 src/data/roadEvents.ts create mode 100644 src/data/security.ts create mode 100644 src/data/trips.ts create mode 100644 src/index.css create mode 100644 src/lib/report.ts create mode 100644 src/lib/utils.ts create mode 100644 src/main.tsx create mode 100644 src/modules/access/AccessModule.tsx create mode 100644 src/modules/admin/AdminModule.tsx create mode 100644 src/modules/cabinet/CabinetModule.tsx create mode 100644 src/modules/devices/DevicesModule.tsx create mode 100644 src/modules/energy/EnergyModule.tsx create mode 100644 src/modules/geo/GeoModule.tsx create mode 100644 src/modules/gps/EventsModule.tsx create mode 100644 src/modules/gps/GpsModule.tsx create mode 100644 src/modules/gps/GpsTabs.tsx create mode 100644 src/modules/gps/MapSwitch.tsx create mode 100644 src/modules/gps/MapView.tsx create mode 100644 src/modules/gps/MapViewGL.tsx create mode 100644 src/modules/gps/ObjectDetails.tsx create mode 100644 src/modules/gps/ObjectEdit.tsx create mode 100644 src/modules/gps/ShareCoords.tsx create mode 100644 src/modules/gps/TrackPlayer.tsx create mode 100644 src/modules/gps/objectCustom.ts create mode 100644 src/modules/gps/useGpsData.ts create mode 100644 src/modules/network/NetworkModule.tsx create mode 100644 src/modules/notify/NotifyModule.tsx create mode 100644 src/modules/notify/NotifyTester.tsx create mode 100644 src/modules/repos/ReposModule.tsx create mode 100644 src/modules/security/AlarmHandling.tsx create mode 100644 src/modules/security/ArchiveModule.tsx create mode 100644 src/modules/security/GuardObjectEditor.tsx create mode 100644 src/modules/security/HourStrip.tsx create mode 100644 src/modules/security/ObjectsModule.tsx create mode 100644 src/modules/security/SecurityModule.tsx create mode 100644 src/modules/security/SecurityTabs.tsx create mode 100644 src/modules/smart/LocationCard.tsx create mode 100644 src/modules/smart/LocationsModule.tsx create mode 100644 src/modules/smart/SmartModule.tsx create mode 100644 src/modules/smart/SmartTabs.tsx create mode 100644 src/modules/smart/useSmartData.ts create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cfcf425 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Фронт (Vite). Реальные значения — НЕ коммитить (только .env, он в .gitignore). +VITE_API_BASE=/api +# Бэкенд-шлюз (следующий этап) будет проксироваться nginx-ом на /api и /socket. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cac4cdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules +dist +dist-ssr +*.local +.env +.env.* +!.env.example +.DS_Store +*.log +.vite +.gps-ingress-stage/ +orbita-video/ diff --git a/DEPLOY-mapviewgl-lilia.md b/DEPLOY-mapviewgl-lilia.md new file mode 100644 index 0000000..8abb840 --- /dev/null +++ b/DEPLOY-mapviewgl-lilia.md @@ -0,0 +1,38 @@ +# ЗАДАЧА Лилии-агенту: задеплоить MapViewGL (мобильная карта) на прод + +Дата: 2026-07-05. Автор: German-агент. Код готов и в Gitea — нужен только деплой на vm-mts1. + +## Что деплоим +Новая «вторая схема» карты GPS-раздела Орбиты — **MapViewGL** на MapLibre GL (плавно на мобильных, треки как GPU-линии). Переключатель `MapSwitch`: `<1024px` → MapLibre GL, десктоп → Leaflet (drop-in, пропсы те же). +Коммит: **`b635959`** (ветка main, `german/servaki-monitor`). Добавлена зависимость `maplibre-gl@5.24`. + +## Шаги +```bash +# 1. Забрать код +cd /path/to/servaki-monitor # или git clone git1.servaki.online/german/servaki-monitor +git pull --ff-only # должен быть коммит b635959 (MapViewGL + MapSwitch) +npm install # подтянуть maplibre-gl + +# 2. Собрать +npm run build # -> dist/ (typecheck+build проверены, чисто) + +# 3. Залить на vm-mts1 (как обычно фронт Орбиты) +# ключ ~/.ssh/id_mts (НЕ id_rsa0404); vm-mts1 = ops@2.59.83.139 +scp dist/index.html ops@2.59.83.139:/opt/servaki-monitor/dist/index.html +scp -r dist/assets ops@2.59.83.139:/opt/servaki-monitor/dist/ +``` +> vm-mts1 (2.59.83.139) достижим напрямую с твоей ноды/машины. Из German-песочницы он виден ТОЛЬКО через ru1 — +> но **ru1 трогать нельзя (правило Германа)**; поэтому деплой и отдан тебе. Если у тебя нет прямого пути — +> транзитный джамп через ru1 допустим ТОЛЬКО как SSH-прокси (scp), без изменения конфига ru1. + +## Проверка (обязательно) +1. Открыть **https://monitor.servaki.online/gps** (залогинившись) на десктопе → карта Leaflet, объекты+треки рендерятся (как раньше). Регресса нет. +2. Сузить окно **<1024px** (или моб. устройство) → карта переключается на **MapLibre GL** (векторная CARTO positron/dark-matter по теме), объекты — GPU-точки, треки — GPU-линии, клик по объекту даёт попап, follow/выбор работают. +3. Консоль без ошибок (никаких `toLocaleString`/WebGL-исключений). + +## Контекст +- Фикс `/gps` (null-safe `fmt`) уже на проде — эта задача только ДОБАВЛЯет карту. +- Референс подхода: `prototypes/orbit-gps-mobile.html` (MapLibre+donut-кластеры) и `knowledge/mobile-map-maplibre-verdict-2026-07-05.md`. +- Грабли деплоя Орбиты — в [[servaki-monitor-2026-06-30]] (id_mts, /opt/servaki-monitor/dist). + +Как задеплоишь и проверишь оба режима (десктоп Leaflet / мобайл GL) — отпиши статус. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..52d6590 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# Сборка фронта (как у Heritage): node-build → nginx-serve +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec5ecbf --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Servaki Monitor — Смартпанель + +Единая панель мониторинга с **опциональными модулями**: + +- ⚡ **Энергомониторинг** — Sunways STH → Wiren Board → MQTT/InfluxDB; щитовые шкафы Navtelecom (FLEX/NTCB). +- 🧩 **Смартпанель (realty)** — Wiren Board: реле WB-MR, диммеры WB-MDM3, сцены (MQTT). +- 🧭 **GPS-мониторинг** — Navtelecom FLEX/NTCB → Traccar (REST/WebSocket); автопарк + стационарные объекты. + +Продукт «продолжает» наработки `Мониторинг ПО/NAvtelecom` (FLEX-сервер, Wiren Board, Silero-«Рация») и +эталоны UI (`orbit-gps-panel.html`, `sunways-dashboard.html`), собирая их в один React-фронт. + +## Стек +React 18 + Vite + TypeScript + Tailwind. Графики — Recharts, иконки — lucide-react. +Дизайн-система (навык **ui-ux-pro-max**): тема **Dark-OLED**, акцент `#22C55E`, фон `#0F172A`, шрифты Fira Sans / Fira Code. + +## Архитектура (модульность) +``` +src/ + core/types.ts — типонезависимое ядро: MonitoredEntity, MonitorEvent (EventBus-контракт) + config/modules.ts — реестр модулей + флаги enabled (опциональность / мультиарендность) + components/ — AppShell (сайдбар+топбар), ui-примитивы (Card/KPI/Badge/StatusDot) + modules/ + energy/ smart/ gps/ — три модуля, каждый самодостаточен + data/mock.ts — демо-данные (заменяются адаптерами) +``` +Источники нормализуются адаптерами в `MonitoredEntity` (energy→InfluxDB, realty→WB-MQTT, transport→Traccar). +Модуль включается/выключается флагом `enabled` — далее флаги придут с бэкенда по `clientId`. + +## Запуск +```bash +npm install +npm run dev # http://localhost:5180 +npm run build # dist/ +``` + +## Деплой +- **vm-mts1** (публично, за Caddy — как Heritage): `docker compose up -d --build` → `:8096`, Caddy `reverse_proxy`, поддомен через Beget API + LE-TLS. +- **Дубль на офис-Proxmox** (внутренний/дев, рядом с реальным Wiren Board): тот же образ. +- Тяжёлый слой данных (InfluxDB/Grafana/MQTT/FLEX-сервер) — отдельным compose на Proxmox. + +## Дорожная карта +1. ✅ Каркас + 3 модуля на моках, дизайн-система. +2. Бэкенд-шлюз (Node/Express + PG + Redis): мультиарендность `clientId`, нормализация, кэш, WS. +3. Адаптеры: Traccar (REST/WS), Wiren Board (MQTT deviceBridge), InfluxDB (энерго). +4. EventBus → лента + звук + голос (Silero «Рация»). +5. Карта MapLibre в GPS-модуле; вход (OAuth/OTP). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..cbc3f99 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,64 @@ +# Servaki Monitor — ТЗ и архитектура (сводно по разделам) + +Единая смартпанель мониторинга: **Энергетика + GPS + Сеть** с авторизацией, RBAC и приёмом +реальных данных Navtelecom (FLEX/NTCB). Продолжает наработки `Мониторинг ПО/NAvtelecom` +(FLEX-сервер, Wiren Board, Silero-«Рация»). + +## Архитектура +``` +Устройства Navtelecom ──FLEX/NTCB :2000──▶ flex-server ──MQTT──▶ Node-шлюз ──REST /api + WS /socket──▶ React-фронт + (gps.servaki.online) (mosquitto) (адаптеры WB/Traccar/Influx) (за Caddy, TLS) +``` +- **Фронт:** React 18 + Vite + TypeScript + Tailwind (Dark-OLED/Light), Recharts, Leaflet, lucide. Дизайн-система навыка ui-ux-pro-max. +- **Шлюз (`server/`):** Node + Express + ws. Адаптеры **Wiren Board (MQTT)** / **Traccar (REST/WS)** / **InfluxDB** — активны по env, иначе демо-данные. RBAC + мультиарендность (`clientId`), треки. +- **Приём (`deploy/ingest/`):** flex-server (FLEX/NTCB TCP :2000, DB off) + mosquitto. В трекере: домен `gps.servaki.online`, порт `2000`, протокол FLEX. +- **Размещение:** фронт+шлюз на vm-mts1 (МТС) за Caddy; дубль/тяжёлые данные — офис-Proxmox. + +## Разделы (ТЗ) + +### Авторизация +- Экран входа: **Яндекс ID** (OAuth: authorize→token→login.yandex.ru/info) + **Демо-доступ** (роль только-просмотр). +- Сессия в localStorage. Маппинг email→роль (allowlist админов). Самрегистрация → демо (план: свой арендатор, см. roadmap). + +### RBAC / Доступ +- Права: на **разделы** (energy/smart/gps/netinfra) и на **объекты** внутри + уровни **view / edit / reports**. +- Матрица доступа (админ выдаёт чекбоксами), «Войти как», мультиарендность по `clientId`. + +### Энергомониторинг +- Источники: Sunways STH (Modbus→WB→InfluxDB) + щитовые шкафы Navtelecom (FLEX, питание AIN3). +- KPI (PV/SOC/U-АКБ/I-АКБ/темп BMS/выработка). Графики **PV и SOC — многовекторные** (линия на объект). +- Аварии: пиктограмма с раскрытием контекста по клику; «Аварий нет» в сером поле. + +### GPS-мониторинг +- Источники: Navtelecom FLEX/NTCB → Traccar (REST/WS). Объекты: транспорт + стационарные. +- Карта Leaflet, слои **Тёмная/Светлая/OSM/2ГИС/Яндекс**, **треки** (полилинии), фуллскрин. +- Слежение (чекбокс), журнал поездок + **маршрут по клику**, контекстное меню (статистика/свойства), отчёты CSV/PDF. + +### Сетевая инфраструктура (mesh2) +- Узлы Keenetic/Mikrotik/камеры/контроллеры; техинфо (IP/модель/роль/задержка/канал/клиенты/CPU). +- **Связанность** узлов на карте (линия/пунктир/анимированный поток по состоянию). Кадры с камер/приборов (3×/сутки). + +### Приёмная часть / ingest +- flex-server декодирует NTCB-рукопожатие + FLEX 1.0–3.0 (params.py), публикует `navtelecom//state` в MQTT. +- Шлюз нормализует в `MonitoredEntity` + строит треки. Проверено эмулятором (`tools/emulator.py`). + +## API (шлюз) +`POST /api/login` · `GET /api/me` · `GET /api/entities[?type=]` · `GET /api/tracks/:id` · `GET /api/trips[?entityId=]` · +`GET /api/events` · `GET /api/channels` · `GET /api/energy/series` · `GET /api/health` · +`GET /api/auth/yandex/start|callback` · **WS** `/socket` (snapshot + position/entity/event/channel). + +## Демо-сущности и локации (КМВ, клиент «tempelhoff») +- Транспорт: `veh-123` ГАЗель-123, `veh-204` МТЗ-82 (двигаются → треки). +- Энерго: `cab-07` Щит КТП-7. Инверторы: Склад/Ферма/Цех (PV+SOC). +- Сеть mesh2: finik-хаб (10.200.0.1), Keenetic Склад/Ферма, Mikrotik Поля, Камера Въезд, Контроллер Солн. +- WB-каналы: реле (Освещение/Ворота/Насос), диммер (Свет офис), вход (Датчик двери — тикает по WS). +- Поездки с маршрутами (полилинии), события (питание/дверь/топливо/геозона). + +## Roadmap / бэклог +- Самрегистрация → собственный арендатор (`clientId`) + привязка устройств по IMEI (свои сущности). +- Смарт: датчик двери — пиктограмма замок-открыто/закрыто, мини-журнал, кросс-раздельные события. +- Выбор цвета трека объекта; редактирование объекта + выбор пиктограммы сущности. +- Реальные адаптеры (развернуть Traccar + InfluxDB-стек); EventBus → «Рация» (Silero). + +## Безопасность +Секреты (JWT, Яндекс client_secret, токены) — только в `.env` на ноде (chmod 600) и на Я.Диске; **в репозитории только плейсхолдеры** (`.env.example`). `.env`, `node_modules`, `dist` — в `.gitignore`. diff --git a/deploy/__pycache__/pcn-receiver.cpython-314.pyc b/deploy/__pycache__/pcn-receiver.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3175937efc064ad2787e1b556a40488f5201c93f GIT binary patch literal 27157 zcmd6Qd3Y4pm2Xvd^}cHt2-Mm@jrIi$VsV5PkP$)@vM{!wBcuk)2&t)V8KZ0pP9_q@ zk+8`_nIW+F=!_`Ui3mU*(VpFKF=%=?{NT~#e; z!1-qWcqRJQt-Ie_x6V2Dch0>OIUQCGm-uMIBgM@e_fPboTp2OX_1AP9H^hltnA^jN zd=nq$*D7IwFWt)eb$6ZK*Ho^@e| zD1@D&A?y;3VYg@sdv@zv^OdG(_txc_U-9ksh~{dvk*;uxxFMV>-WyKaV+g13xf0IU zZEdZTM4&l}DA+lezh}L^zWo1*ERkxW-Ot_659rId0bQes z;|`-e62H^ESGUE&aTabVH=qylK`xvv=BIOFK|0s#4tvFA@GocnoKy~NEX1=<9hcnQ zx#+WPw|%#Jx1(B!wLVW=kL-0h-=`lVg5>SEpk+e>)=-JUMa3${Q$>x zFXp%`ZZWr;Z|%KxM9|{u98MV*Wz4fN3-j1-K$jgGiyqF)?Y_szbsMO>Y;FmQ@8Q4#QyXG=B0u*Y z><=9p=n04W(X7tH-mTl)+Pqbz6{Qu$-cY!^w`Xrje_!trpB1IN7Woxx|Na3qyKG&o z!VP7CM+2z$z8%|IO0CrC??fgdPesNe&wJ7Fv#9d5$!|r@PL6sbKSxW4&@Qyc`;S8> zy%>^nY*1c7x|b)9M_!$LBJv9~_0`C0-pF4g^5>}a7u56xLs)8U+g87=2H}W)8L4BX zqY&hI0gr#e>d?lnPOK}N97f3hXY*)LIVd& ztu2Grit^Hx<)!7N)vJ3R!WYu>Niv*I;S8FBU412ZorBJ@M?0mmP_Vzhw+vx}wnGDf zfk5daA&lPO@`A$7P*=F;V89phK2%WHixKPX>!ios-h#q|fl#P(KOTI#sDZ^rjr;ng zgPq~19%V&!8*8INLq%12RM*IS74Wq(UnP8-WnV>9*CwaCC#u`liX7GD2&-$33L^8> zwjr#F!fHi?G2ccxtTL+GRY$QatE0LW#f+;;w9UgSFvBVVGfBSzFXu1uH5`SkD8bCmCk8l^yZK(fiwxJC8-1K}vYH$=5A^LnK`3QY4KJQNQ5rNF_?p1z*G z{r=uS-~RB!QV}BA_-%z}h?}z}KT$MPH*0my+8ty1CpQiAvu4Y1*OL}_Z1&+p$5&EB z%9BmQwXvj z)!!Y68qwi_u5eTcc6J?z>hT7BI;jL%q!n;N^ieH~y>5&Ax@m;y?XppzL_-{2=cswa zJeEFX&K#9$PyWKD}eY^Jnc-w)}}h*KCCmp->(bWKl2WBfd_hb)u-` z_Jke|NCQ_d0J9|NIqe7@3GtB}z5$Nm&)rbns z6kzc8;^s3TKG3^QT8k>Bb>xs>DQ$ogqAo9^OC~>NzaL~L?Dt307QFFNC7j4lh=>tp zXiCO$i|l0P9(T?%CnaN8IBt0_b?K=+PwkmXEf^NQZN2S>Yrg(P3?7I&%9F2Fbug6> z4q_@kY;`ELaD>K@d?ma}x5q5^b6uRitF~WIKCD6Z@v@}Kbz|y{>d&;XgAjA>|CY#W*){Os84+l zq5&U~@qIHAKg&l=B+L=l6-Z!~DiJ!E%4FxdZ7nUebz8jqI(vEp-8J48p9f?dA5S10 z^>pE@f)LZy-`5v_JlNkC_azb4mOrOl@=+V5mE_RZ#kjUatHY=%1oq$A+Zz?6K-Z&D zLsxHqC=hjF?GP)oL6Z6<>0YGunWQ?(OdKO>3U(gp?eFZ4S|O?WAI9e$kfOrg{_Z1? zG7q39QXmuz5z&AML6`C!$69;KcN`0EM~=|n!GSDcPnxmkU$y5?bpNF12R&y~UdsGg zW~5;4>)WU8kSpFxT0CC=Ov`s$rjm+=O>?PB#~+bH=nJ-WI)?G_o_sXNc z`qD4Ibfr47{r;Kl{>knBse8Y8E%l4T)>&8PHCNuSFy~AjYa45P;^6es>KAsLOZlr^ zQ%mo?=Dhdy{Af)kgNQS_X=Ky!rn#h?NdB60`M=1BZqXZILNY zJnf*7Vr2c_K*E{GfPkB?wY;An&<*JQs*s7VflEZ2_IcQRp2tDUzOxZ9C)-x0S;6z5V;6ro){QN$%1%1o;f?TaFsS z{lT6tX*)}Gs6Q0t11jr{eX=H{%+!UPHx?W~*3e(U0poReMjsq~@QDX!UCEOU*j%k0h6T#91As@41st*-qFZ zS+%cMOuHNAb%G~zHhuA_Bd3mx9huF@ddBiy%QLR;x~4OH|2mhx^dpYlV(tJ#~eq z^EqjCd8YHLi#HY+FXZ#^UnsC`tg>7v*O9-HCx2BD{1=xRko97o0l6*~>d9YZt6OL3 zQl%{vA6pBUufN5{NLwdra(ufk(F#vnA1q*ydTnL5r#z1Fs?$;v7uxj-^Hc8eCAI70 zpMpYSF{D)$G|>Fmr7?6F&B7|80#Pks5RC0q;ejozG(ICwQWDZ)Gf&!0iD=$QJIHB+ zqs+V*vygTn>=r%|n|9#ITxbg%@CjGOcO|5?a!T#=n zUh)HGI5g17a2Y(g{QiACQYhTp(--LLM}W!i@9ysc08x)Z*w-t4k$kMrseXS5_(V^a zzcU<`diD+g)j%GP-`~~S847jxbcMSDy}f?FkB>Uy0+Edt0AG;TuxqG1a+>f9<-_^( zTNthnbiAR1|Hx=EEc-CYVMzOEsoPNcNxt5YJ6Ez@tzXU+Zk0&;^F{0@3vqJrWzR-Xakiv+5oRZM|AL8b9dPDJ?**a=F zW;@w*Mwl^|d^%@K0P4S9-h*)c|1vp2BPSZyyE~ZB&WV7P%5fh4qUg&C_#O@J|P)VWn)M&`b2U@m6*O&U3tjuAXmw+ zkUz{m3;k)*Oh=lMI?<{_w6@31InhEh_f9;#xlrcdkUG@Wg*v~5xVn}owQ6_K7B3TF zep^V2pgLt=-2Ao=oI<|H7()_(bpz=jgu772@{0BmcHr{@)?g4j0CE5xz!dQK0!zrC zd;;@F2(th{0SJRZA>SfE9bqp_9*0`s2gpU}_Aeu6y_4S{!~_64VJBy6ykE*?f z%!VEOoH={ioHOU}oLn*9b!O9yqxzbo`WzoyiHy}B->NN2?9S26$2N~`KlQ+g2cCX# z+V1_t#5q@fWacdPQQL?ul2U%Q;4SlNlF(M7pwA$p*^`NQQ8h8@)e>plK4TWInngyw zcJMbsgd~&UE;~K+-%%ym^u*Ozrv0N0)@L#ms>Q?J7rl-|a=`U6p;O{~n z^&;BBXxk9nyYrYtl6r^ASCnsld8~LD94F=5ojF&lO5xOYakrLAu)RhBS7VaOx(imm zUCEh)&-9znyDI#G1-%4*Knw%?0Qawx zwv^Pwq!@nLyTL1y(XF))Km+>%k`XtOG4aW-6Bj|yi;-WD8WjwPf>;{B>{eo3vFvSP z(^hXGaV&=DqaBl@$o7hN@|&d1rEK2yRO{KuD?r_;AcVijw7u*hPHF8cZ5ddHKFRDv zrv4BHyS+g8&!Qzq(P*afg&ukGD6o1$_0KV;_ie^bkv6K6_TCUm!v}*=-Qm51De+|~ ztjU)ua|-BL!Vg3HLV{>W8bZxc)BZp>)H4W#O&uNo#$9a}DbJxK}u+Q`>V z4^FvO3=6Y1*U5XQZMhT)ros4%7-@T8`oTx0%?D&g)ji|Ln|9<)93tM-IMR5sYV6Q> z{&Y^owM7+E$(7UgDvG#o%=J--UA3OO^WFpsRP&OZ;(su^Ut(>pz7jLXAyE zG2q=h5yP}D4-gnRhnxb_6OErm?cu1fl?S#n?E-5jn>)j$MI z9ZQwC)*QvB-P=r}F<$e4XH)#M#z_*i1zJEdu4?1=fzPA0daeH=&vWz^+IXc$-9KgT z9PO8{))k6RyJH1ktUQTU(I(oJ6&^F`8j0hrrAj*Oj((eAT1M;3JG|jGT`K`cH(i>+ zLN2OA%NJtEG1U=aDa_DZuPwngXq+0wrF%i{@PoTEBL!l;<)T z$dTNV%{A~_zJ&I~WS0f<5N7X9VZq<4Px2{Ri7L)A0}y%SA^Sc~qXUZ)S zoQ2pqKvYaEPJSnNd6XOT?Wm2_>fcz~R<}87fEiLxQ2H{ZF_6+Z6ctF* zB_rmoR5H=ssHNetu0W6!;Zbuqa4<*+P1JPoK!_eBiIN!ib;}qJ>nDlOp_>F}={qbP z4Bh(4fTgDowv($61Y;ve;4m^o`^g3l`fgc}Hpr#;DI4D-eUt8b=;@-mecjSHoe;-8jpCbYNLV19Cf%}*_Ks5J^+%vjiWW(tdGq!@Owu0x9o=<-^ z{rT)?vtL;><*UEKPy3qQcDZ3WH&hQj*HF{E&S3D|3IL^~p4xI^%hOwj8$Qt!%46kH zmWl}y1|jbC%!x^yyHrqwY$e^VW3z4wmoHDXSJlwC8eEO z@zjd(wyC7t;kr4yd$f6^c|3W1=l8NELTA?e=&RFqnC`Jm$Lc@ua4D7ZUe4^fYRZ^R z$vj;&UK~kTK5Us6Y=+WzY$xrSzlR}gI@WaZk!$vxPmG+|P9i)E*CL*cuh;)B?@G^aOQ+4d zuu2))^2t_SM!F&ZyWS(5UWbuXkgjEC_-XTszjL`in>Qf*vyU=3PsZu&h|Bj8r?(`{ z*?g zRHh~E#wSVW0eT(rto-aFXAEsCRh!^DQvnsiRWmf0P!*Kk2t9+zaz1m>=H=Y^D$nL* z!v(<%|3y=+z9~(2Da*7ale?5(%i(r8zt*_LV0dFi9vzNv)%yzZwlcDOA1AHF@4%>Xq3yog8b&ic3og_(2`K5N zE0I*S3B39dNS=@JL;7Ze6*XcNVpcLHvbRdK*3e`ZMOstkG^idSr`@?kk5Zp@zqQ1e z@h_A6pn^>#qwpOmN(qXrd29c+fNpJpUSP|2InKm258`Ks^rh-IRN0R2$WTg4WJd)( z8?V$NZ$$|5_wrjVG3f_B4Ix^o$L0k6z#)_t`5{Z=qNJgN&1x(TaE4hnc(h}poFeK~`D(uSVjGAJGlTjyt z7C+lcz>Ri}_+p?;tOB(2d$)R5npBRmneuTctNDVP8n4Jq$zYL&|mQKO- ztuk?Ek-tRvd-#P&bNW7JJ}AtLE$6B&XUdib+HG~tIa5au9y@rtWy)Cum0-kN1hWTA znS!arAuZ4CeSZJ5`=9TBw*Qq!rb?Tx)J~UfolRRbleS_qZN-`D7Zy*Yt)FqPzv^E9 zfu1XC;U`_b4-A~8_yZ&7+3+c&Mm!2usL_fuwKMk8tM*c&E0(f9gtnuJN2}}d`M>5j zRtPT%@V!#FaV2-Ypq9t&!ZH{?2p5(Maz3~(~Jnem}Q`l z96kJ9>Y4|o(>DQ1z+L|rl@gFist7b2$T%g${n}(vgaA?pd?+O|;%G-e&rAN`(mO)GF{ToS1SP&Y>L%;$fDaAw*(Ip#HDb?qc zKw1gzgoz~+A*HY2S$ct-7s)wC&JW4?A~~e@mw=CO^2bi!x%4eKKAZFk`CldHHFADM z&gty~Z-&4M)i>91Q=Urx-aj0%SiF2in?Hp?wZ;#mXhZ^Rb zNu!S(dt^NM%*ttJ#n2|$AjDCgd7aU)nV)qg%{X%>ow*a$&!s${^=#JJfhlL>jJYvl zZj6acV~fWFZ<$L#QY@SJPv5hpK=47%_Y?`~o^zgQ^V;}=mYg$8IXBIi>ECDbCQAK> zPz$f0?bWQh%YMysiWkgs;mtUd%O+ zf4LE9E|wS&d$Cdv{{qVh`oi&52-n-;Aqz^8*YHSDx0Q#T2q=slS|75@4eH~tn%LKb zjOs;nNclE`!p`a{+IeIcXhH(A>*?9o6Og>iE0(YC3mh(M@D_WQS1n%;qgPn@2g*b* zc7oA9wkjwms=RFf7`BSR=)VvfuZk-w%8N@&OMOwkD$0vdz9CAy#9G%>?{C}Y$2PO5 z9_$PD@fNizs1aCs(rGk6n}0ErDJ(2v%L;r@{&2rP*x4gVe~Y9RklD~mcrc|>mP{m1 zr{s_9IFmQjIO|9r-aOVmwmD*5GE_HbO&Kj6DTPqKcgmWB4LmS#T|Kmo83SEM5uCA$ zT>8;PG?>QhdOn=H?cXZnF>*S#`-ZKKp5-N4S1#D5#agfJy2+welPE+xvMPD^2jraA zvm0v@YYpPrWr8cW2JY>)+bEbHX>JuME~XO(ZUxCJ!5mQYV4DhboE$J`*mmM)(2Vdj zGrm2h)u0gFokV%td7#BA-6YwG&BYzDxCIvGvABjL^zXLjv; zG?B)7k81iSE~rbH(G0__M%fDx=b&L{-p+<90XlM$*x41=KO^hkg9-a*T7%Tx&9S%z z*7d9p3wvck%?sKEoBjW!9*O1!qowtTR^jYQywwF#2bWM^4>?2`HRT>Ox0{tY)D3cj z_iavOiM&VM7lAQIoI=%R2Tiw0p}xtW@ir;cy%Cs~aigfTR({vYn`PT~EG`AzT(S|@?PmT$bv6=8n>u! z&Fc669T%*T|D2u;pff=s-Uhs(1i`{1{ zuNADCO1@{rjC}-3!dX|^_>PEcS;V{yfWFx6w@jJi<#SpeKaSt^rGm2Vhs}k;MG&D}nz3^*ZntkDO(s?fI%Q_l zsvhflb$!vQuE&*fW4p9S1iR@{X#1YWMBP2HHbWBE$4la5xu|^Svc>+^zfEk*C}PDT z8kKSq(h-xgHi4qV<5c4?;A*g0iTO6`W- z3}}+!Fh*V4FO9_6++dxv^Rb;Lt7Bt!bC0vRt)+1-Nmc66?y!lBZwWDJc0ogE!Eq9H ztl)&(myWleuH?|}8X}>L(cLVLZf!R-L7;tDjos0v2V{~C)a7W4o5@SKX1}H%Mj1I&M$j5p210JWL}&a8 z4l6J{t-zYUA>Xz27uu}qo>4aDg}e`@d05kBk%stA_E4A`p4*k9?Wc)rZ=!kZitn>h zH$#%#QFAa5kjRvNFhx1df_A%j_euQ+vE-9BqxUU0Ouev-?<=WbYy@_sMrNZ8Er-K8 z=u+}w9SPNkgzarIR~W30#ngBQ`WV0HMl>xHW9u;3p?c}WiU9)lz4y0lY1y{3C2ER! z7{?n-S4zhcv!}Z^;EiqD(;^=3>_fO}Wj>gu#^qpDueA%;QQ!CcKzGFJGX(k!EiFcCIXWgmbm8XhM6piUoJ+JT}ou*5B! zG?y{$Ksi}0xigW(;(6QdCScD4ErZsMCtDOCP>sJqOL?VT*UaUA2)&P2cC>J_ga7G@ zhJ5aPD&MeJIG<)Ce=fht#huUd!GFO~%j0&@$u}$&F1kv|zn*XK3KusN!+&W7-;gU@ zDl(D3%H6nuyR6SZnU@z+uFFfA>t${(bMuQD*XrMJ=}^HN9y9qD@#HQ}Yh0y&qryPp zs|*yfR!{y7woP{Zn-(3)eAC90->xUWi{EV3zv*#r$`#(sVg6j(rgGDpB{~W(vTpO21%f3(_brCd=Wu+tT*dqeHsF7AE-kPoTXDzvC3N*?C(9&*MG35v)5bx*y{4~m9*P&UoUnx2cT4@T?nM>hY~?P zy$Je+m!Ty9vGbMo@Dn&f=imRGK!vD5>g>buH-hx9C>#i*)Wwdsi1Pjr4VY$pZ}E3` zm9Ozrf`3B?NZ^WFBHz7yLtOdsOK`b- z^Igg}+_nA2xbiLSmYdpdxl8$RM;6^?e4vm}$H&@kRm-no<72%``TF+6DKrQ{h!afX zK3L@)0n$`A6a;l!AHVB(nB&@OYqzQG4Y2mw?%dwBypmpq*=X}s(B7Unm(~ZzZBl5* zI|%J|(HaNs+5qj^gU)s*pj`)pc6Hj;j6~oVshQOGpx>FavwCNHl@SEVG4K?|ihW_K zNfPJ`87;|%;IF|}iHzRy_`>Al-sVO!Evge?X!SD$LdO|1MM4@7C;6zD$#2pbYl+N_ z3S-)kVlPlz9FW%2S?nb>2)uzq#Sk%p;WdD723>iD50#g!sV!;jEZK)dgDGV!$L=r1V4R9#06BxcjxQsJ@7|8ky)mRG z=0hqau#!^VD_z5=NNq526Nikg*V-!rh;YLwN&i5_zDv$&I3X7t6%LZc zP+11G#6Y2*PRWki89l6n2b(jEi9nxOs~hl-%QLhYQW{K0)*M?i=AQ^K@$?;&Z8kam zRLzN+@y@5$51V7AB@w7x8>Z})Lk;hk?6c0~(E}p~#&e$N8`i%EOO##5c16;CXI4%* zDux>0F*#zA;rOAa%Z5#|u?oynB55UO2Bz#QZ!Kfil`{IJkuQx`Jn>c9%A7IUH_|u0 zVX&oK0Okwt2j5yfKoL2OFQHtW%{YN+*&h_D&`FhU?z7C5qM*ap|8WvXOA$?}Bp(qk!{J8`>nNFK^T zB9fjgeANf!oD9v=?T0(q?Ya9|pxpL&?xpg+6cjQPZ%`bc3T4y9PU4cia1$N=qp5~f z3Il_R1hfzeC^~=#3TmNU57$7dsY)Y(Ly3-5cVcJ8hmjw|ObxLi<~AoS#zZrSWF<^o!=|6o!6bjd=9+@7 zH3d703l5ePbe9zDit^=yhFm1e9W;pL&E)4S3eNt z9UwOs>Cue!?><^~M~(XrGL>!>B_;Vyw$ii2&7BLBLm(m|pE0n4QvHDfjj_|~h%m{J zvO+{;>fzWs(9_#ZbO$u(fRv78Y>tsVBMdk!j?pE@mPFEvo@;ylq5t~OPj^pQ)(&l) zwIz*i7~U|}1)~l}(pc44!Po$OQ4_fn;zWJK=0mud?XVo%G|@C{n=+T;05vNhpTpmf>|%w#tYQ zljY)PD$R?>@w@)6@c$d+svTbi&{eO1rGWB;yMV6OZUedkaurQVIXHAi9fw4c$wCOV zO;`Y!!X%0S(}l-Ei54bhKygHIqIIESsojte=2cJE#W$+Kw5p2cFz5|IveHl6jr$;T zXK|2Sj0*r-2LZH+3_ldeIu2f4FmlYuO@&u?{aCcKqpMggZAM5*PL(Fv0h7ip4>4cZ z)oxdohMxm~y{T>hurT#!0IVYTDaGQhwEeE2Xz~I87OF@Fz}f-49>Q-W0LUz*SOA%M zG=f1#+?&rA#_WgKj#SHy$fyHVY0JF_!*2l4yKo&_;=405M}S zHSdGIb1f}W67JIfpw!H^$Aw3nO@P$kRbIvt%ILT>#oll*gW?Db#`&5DgS~-ppiIVi z2a}bUc$k<4-V6~ZQOcX(;S}kAQX>{(!qWdj7+IfpcH>~?o?hvHQ<{Gvha_AX4*qj` zBz7h52_=&v*^a3LzG!lj+E5Y2$f)oKltx2^ozjOCaGjiw$oZI@^Khbu9vliMB@*Oi zrfWpKQ9UtCX7>bYAfI(`6Fh8H;9%jR`?h7v%-?`hER z%!-=H6*cEpPOYfDQZ=2qZ6ONQ=#O|0QQ{e zW9Hl6vy2&Fx_+wpMDs+-RLZj9hPjm+&J9ehY?!oH4sROIo7e$G>#?uRF3KA-%_U_| zn9ii0)t}3M({kQ&(KeOTJY#Q;*qi4CgJV6R#&ze8OjmDyHvLN1RGK)ZpLM4(v-Ta+ z?wr}QtW&)wdY|r_apzCD^XF4EpmqUZTrd%S%Ut|nEzhO2@*gbX9KedLoTuQ^x#TQh zy{JbyIBWH}oEi7JDfc>pgB|P1pnb-+Y|^%jP~$2UOfEXp_LjMF0n*G2&KV56?2G_| zuNcyN^N}@^%8@nyA*2}{x@Io^Lns+9{%B!SDgV==#!T*f8bQYA({1F><2RRc=kqJ! zzmQ&=h1ExEPkV-T8 z*Yf19OWT~TzmjA?$}8yxgj~telRw+GxkP_ug$}h}Ddx#vq9=bj-&6z>dv}vw__dDw zzt-EDJf>eeb>w&RdDI?u-L^blJ03KS^DEi7R-z3zT`=QUe3d zYn^5gt0|bZgLxDw3Y$!6`>~!Ktf*6J-UI|RUMfW@tm5vTDsCKAo~N`tW{i+PLQ!gk zRCcFW-R;Jk4wr~OfMXM|+l8&adwfjNGC|7v2mELQK@Waq_~p>C6|79i0Gi3K!uD@H z%&>X2yz!Cp|FL=L2o*WEyuo15M2F?$uVt{lwXTJZKxAjRdk2Er|9VhU0050RsjMBq zBk6YO$a3&!dMcC;YJl$5=Vr#gQ9F)UlfrSvE( zO5uo|XS8ns=gtSDUm{Fn(@_fOjS+tC*DvW!EN(YPOHK|s0cVD*t^7xtVIcI25t z(xap)jd{QmnH?~Mb`E?~UXBX1;Y=z5r6jBQ(BB}Ac;#Hm;+d4ft0{%gEj^ccsd*}8 z`;2ottbEnrMQ3+iGEb#6%{ZG7EO>C1kUe?Eo^#cn^TQQ?Q9NZYgN?AFd)+bT$UMFJ zhr3>|yi)TKwi#{W|Jju`didDkXH3tS{?tU96#9!71#{lKV!Z>V!j`{%rd>tm_`qiHPMF-&L#41JDb3(-09yny{*PmkvAyO=;0?0 z{~;7XmyYH)tmJ>p*Ov%CSq|TMCtqJKoOjh0;^D$Qe0`<)!fHMF*SZ=q3>U3@eYO3f zO;3L3>iQbXB_m(I%6`ctkl$uQ_@y+yezo~hxJvgGfsikpS)B*$KW>kw$gNdO6m2JRTSO-H4)s>HSyoZV) z%TL*Wo<0#~EH57)@^gA*pU7qM{Vh5Fhn#;RhkbFn=0^Od5~2*?Ffo=1uOt?-`7Z54 zQfvzt=!<%`1di%Z%^!&JkFt~N0gvZVTJt*j2*wT4S`>nRT{qxvd$*u4Y_8M^CFyk){}&sPS23-UCuPaC12%| zXRV&m;*sK6TlL2VJ+GTLb0+7EG5xABecUi*Tsovjh`}0)4}iHKlG;ZB_EcT@$L`1 zc?)0mVMa2)ng6iTz^BZ6Iak_{?PDV>vp;-<&*m%U+j!1s9n#GjEJM1FjnG_wya?*X zj~_tJst+@kp`~f%k5>M1W-T0PyF_fpmqJO=<-N`wt vKAmre7Ig^;u8yeLj(2Eq+z8S3wF@vh-wBkfr~BzVh7S literal 0 HcmV?d00001 diff --git a/deploy/ingest/docker-compose.yml b/deploy/ingest/docker-compose.yml new file mode 100644 index 0000000..ad4bac4 --- /dev/null +++ b/deploy/ingest/docker-compose.yml @@ -0,0 +1,51 @@ +# Приёмный (ingest) стек + шлюз на vm-mts1. Лёгкий: без TimescaleDB/HA/Grafana. +# Устройства (трекеры Navtelecom) шлют FLEX на :2000 → flex-server декодирует → +# MQTT (mosquitto) → api (шлюз) → REST/WS для фронта. +# +# Раскладка каталога (собирает деплой-агент на ноде, /opt/monitor-ingest): +# docker-compose.yml +# mosquitto/mosquitto.conf +# flex-server/ (содержимое navtelecom-flex-server: Dockerfile, flex_server/, requirements.txt) +# api/ (содержимое servaki-monitor/server: Dockerfile, package.json, src/) +services: + mosquitto: + image: eclipse-mosquitto:2 + container_name: monitor-mosquitto + restart: unless-stopped + volumes: + - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + + flex-server: + build: ./flex-server + container_name: monitor-flex + restart: unless-stopped + ports: + - "2000:2000" # ← порт приёма устройств (указывается в трекере) + environment: + FLEX_LISTEN_PORT: 2000 + MQTT_ENABLED: "true" + MQTT_HOST: mosquitto + MQTT_PORT: 1883 + MQTT_BASE_TOPIC: navtelecom + DB_ENABLED: "false" # без TimescaleDB — экономим диск + LOG_LEVEL: INFO + depends_on: + - mosquitto + + api: + build: ./api + container_name: monitor-api + restart: unless-stopped + ports: + - "127.0.0.1:4100:4100" # наружу только через Caddy (/api, /socket) + env_file: + - .env # JWT_SECRET + YANDEX_* (секреты, не в репозитории) + environment: + PORT: 4100 + DATA_DIR: /data + MQTT_URL: mqtt://mosquitto:1883 + MQTT_BASE_TOPIC: navtelecom + volumes: + - ./data:/data # персист арендаторов/привязок устройств + depends_on: + - mosquitto diff --git a/deploy/ingest/mosquitto/mosquitto.conf b/deploy/ingest/mosquitto/mosquitto.conf new file mode 100644 index 0000000..0e3b61e --- /dev/null +++ b/deploy/ingest/mosquitto/mosquitto.conf @@ -0,0 +1,3 @@ +listener 1883 +allow_anonymous true +persistence false diff --git a/deploy/ingress-watchdog.service b/deploy/ingress-watchdog.service new file mode 100644 index 0000000..f090ef5 --- /dev/null +++ b/deploy/ingress-watchdog.service @@ -0,0 +1,9 @@ +[Unit] +Description=Ingress link watchdog (MQTT broker reachability alert) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/ingress-watchdog/watchdog.sh +Nice=10 diff --git a/deploy/ingress-watchdog.sh b/deploy/ingress-watchdog.sh new file mode 100644 index 0000000..9d0c8ad --- /dev/null +++ b/deploy/ingress-watchdog.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# ingress-watchdog.sh — алерт обрыва связи ингриса с MQTT-брокером (Milestone B) +# stdlib/bash only. Аддитивно. НЕ трогает приёмники/буферизацию/маршруты. +# +# Проба: TCP-connect к брокеру vm-mts1 10.50.0.48:1883 (таймаут 5с). +# Дебаунс: DOWN только после непрерывной недоступности > DOWN_THRESHOLD (60с). +# Алерт ТОЛЬКО на переходе состояния (up->down = ОБРЫВ, down->up = ВОССТАНОВЛЕНО). +# Каналы: всегда лог + статус-файл; Telegram — если в .env есть TG_TOKEN и TG_CHAT_ID. + +set -u + +# --- Конфиг (можно переопределить через .env) --- +BROKER_HOST="10.50.0.48" +BROKER_PORT="1883" +DOWN_THRESHOLD=60 # сек непрерывной недоступности до алерта ОБРЫВ +PROBE_TIMEOUT=5 # сек на TCP-пробу +STATE_FILE="/var/run/ingress-watchdog.state" +STATUS_FILE="/run/ingress-watchdog.status" +LOG_FILE="/var/log/ingress-watchdog.log" +ENV_FILE="/opt/ingress-watchdog/.env" +NODE_NAME="$(hostname)" +SPOOL_FILE="" # путь к queue.jsonl (задаётся в .env на каждой ноде) +ALERT_WEBHOOK="https://kpgen.servaki.online/webhook/alert" # единый n8n-хаб (можно переопределить в .env) + +# --- Загрузка .env (TG_TOKEN, TG_CHAT_ID, SPOOL_FILE, NODE_NAME override) --- +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + . "$ENV_FILE" +fi + +now="$(date +%s)" +ts="$(date '+%Y-%m-%d %H:%M:%S %Z')" + +log() { + # НЕ логируем токен. Пишем строку в лог. + printf '%s [%s] %s\n' "$ts" "$NODE_NAME" "$1" >> "$LOG_FILE" 2>/dev/null +} + +spool_count() { + if [ -n "${SPOOL_FILE:-}" ] && [ -f "$SPOOL_FILE" ]; then + wc -l < "$SPOOL_FILE" 2>/dev/null | tr -d ' ' + else + echo 0 + fi +} + +send_telegram() { + # $1 = текст. Отправляем только если заданы token+chat_id. + local text="$1" + if [ -n "${TG_TOKEN:-}" ] && [ -n "${TG_CHAT_ID:-}" ]; then + timeout 10 curl -s -o /dev/null \ + "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TG_CHAT_ID}" \ + --data-urlencode "text=${text}" >/dev/null 2>&1 \ + && log "telegram: sent" \ + || log "telegram: send FAILED" + else + log "telegram: skipped (no token/chat_id)" + fi +} + +send_webhook() { + # $1=severity (critical|warning|info), $2=title, $3=text. Единый хаб алертов (n8n). + local sev="$1" title="$2" text="$3" iso body + [ -z "${ALERT_WEBHOOK:-}" ] && { log "webhook: skipped (no ALERT_WEBHOOK)"; return; } + iso="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + body="$(printf '{"source":"orbita-ingress","node":"%s","severity":"%s","title":"%s","text":"%s","ts":"%s"}' \ + "$NODE_NAME" "$sev" "$title" "$text" "$iso")" + local code + code="$(timeout 12 curl -s -o /dev/null -m 10 --retry 2 -w '%{http_code}' \ + -X POST "$ALERT_WEBHOOK" -H 'Content-Type: application/json' -d "$body" 2>/dev/null)" + if [ "$code" = "200" ]; then log "webhook: sent ($sev) HTTP 200"; else log "webhook: send FAILED (HTTP ${code:-000})"; fi +} + +write_status() { + # $1=state up|down, $2=human message + { + printf 'node=%s\n' "$NODE_NAME" + printf 'state=%s\n' "$1" + printf 'updated=%s\n' "$ts" + printf 'updated_epoch=%s\n' "$now" + printf 'broker=%s:%s\n' "$BROKER_HOST" "$BROKER_PORT" + printf 'spool_lines=%s\n' "$(spool_count)" + printf 'last_message=%s\n' "$2" + } > "$STATUS_FILE" 2>/dev/null +} + +# --- Читаем предыдущий стейт --- +last_ok=0 +current_state="up" +down_since=0 +if [ -f "$STATE_FILE" ]; then + # shellcheck disable=SC1090 + . "$STATE_FILE" 2>/dev/null || true +fi + +# --- TCP-проба --- +if timeout "$PROBE_TIMEOUT" bash -c "cat < /dev/null > /dev/tcp/${BROKER_HOST}/${BROKER_PORT}" 2>/dev/null; then + probe_ok=1 +else + probe_ok=0 +fi + +new_state="$current_state" + +if [ "$probe_ok" -eq 1 ]; then + last_ok="$now" + if [ "$current_state" = "down" ]; then + # ВОССТАНОВЛЕНО (переход down->up) + new_state="up" + down_since=0 + spc="$(spool_count)" + msg="✅ ВОССТАНОВЛЕНО: связь ингриса ${NODE_NAME} с брокером ${BROKER_HOST}:${BROKER_PORT} восстановлена. В спуле сейчас: ${spc} сообщ. (дренаж до-дошлёт). Время: ${ts}" + log "STATE up (recovered). spool=${spc}" + write_status "up" "recovered" + send_webhook "info" "Связь восстановлена ${NODE_NAME}" "брокер ${BROKER_HOST}:${BROKER_PORT} снова доступен; в буфере ${spc} сообщ. (дренаж до-дошлёт)" + send_telegram "$msg" + else + # остаёмся up, без алерта + write_status "up" "ok" + fi +else + # недоступно + if [ "$down_since" -eq 0 ] 2>/dev/null || [ -z "${down_since:-}" ]; then + down_since="$now" + fi + down_for=$(( now - down_since )) + if [ "$current_state" = "up" ] && [ "$down_for" -gt "$DOWN_THRESHOLD" ]; then + # ОБРЫВ (переход up->down после дебаунса) + new_state="down" + spc="$(spool_count)" + msg="🔴 ОБРЫВ: ингрис ${NODE_NAME} потерял связь с брокером ${BROKER_HOST}:${BROKER_PORT} (недоступен ${down_for}с). В спуле накоплено: ${spc} сообщ. и буферизуется. Время: ${ts}" + log "STATE down (link lost ${down_for}s). spool=${spc}" + write_status "down" "link_lost" + send_webhook "critical" "Обрыв связи ${NODE_NAME}" "брокер ${BROKER_HOST}:${BROKER_PORT} недоступен ${down_for}с; в буфере ${spc} сообщ., буферизуется" + send_telegram "$msg" + elif [ "$current_state" = "down" ]; then + # уже в down — не спамим + write_status "down" "still_down" + else + # недоступно, но порог ещё не перевален — ждём + write_status "up" "probing_down ${down_for}s" + fi +fi + +# --- Сохраняем стейт --- +{ + printf 'last_ok=%s\n' "$last_ok" + printf 'current_state=%s\n' "$new_state" + printf 'down_since=%s\n' "$down_since" +} > "$STATE_FILE" 2>/dev/null + +exit 0 diff --git a/deploy/ingress-watchdog.timer b/deploy/ingress-watchdog.timer new file mode 100644 index 0000000..55c2db7 --- /dev/null +++ b/deploy/ingress-watchdog.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run ingress link watchdog every 30s + +[Timer] +OnBootSec=45 +OnUnitActiveSec=30s +AccuracySec=5s +Unit=ingress-watchdog.service + +[Install] +WantedBy=timers.target diff --git a/deploy/mqtt_subtest.py b/deploy/mqtt_subtest.py new file mode 100644 index 0000000..3b80a25 --- /dev/null +++ b/deploy/mqtt_subtest.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# Мини raw-MQTT subscriber: коннект к брокеру, SUBSCRIBE guard/#, ждём 1 PUBLISH. +import socket, struct, sys, time + +HOST, PORT = "10.50.0.48", 1883 +TOPIC = "guard/#" + +def rl(n): + o=bytearray() + while True: + b=n%128; n//=128 + if n>0: b|=0x80 + o.append(b) + if n==0: break + return bytes(o) +def s(x): + b=x.encode(); return struct.pack("!H",len(b))+b + +sock=socket.create_connection((HOST,PORT),timeout=8) +# CONNECT +body=s("MQTT")+bytes([0x04,0x02])+struct.pack("!H",60)+s("pcn-subtest") +sock.sendall(bytes([0x10])+rl(len(body))+body) +ca=sock.recv(4) +assert ca[0]==0x20 and ca[3]==0x00, ("CONNACK",ca) +# SUBSCRIBE (packet id 1, qos0) +sb=struct.pack("!H",1)+s(TOPIC)+bytes([0x00]) +sock.sendall(bytes([0x82])+rl(len(sb))+sb) +# SUBACK +sock.recv(5) +print("SUBSCRIBED", flush=True) +# ждём PUBLISH до 12с +sock.settimeout(12) +buf=b"" +try: + data=sock.recv(4096) + print("GOT_FROM_BROKER:", repr(data[:300])) +except socket.timeout: + print("TIMEOUT_NO_MESSAGE") +sock.close() diff --git a/deploy/n8n-fixerr.js b/deploy/n8n-fixerr.js new file mode 100644 index 0000000..7c25324 --- /dev/null +++ b/deploy/n8n-fixerr.js @@ -0,0 +1,9 @@ +// Вписывает settings.errorWorkflow в экспортированный воркфлоу n8n. +// Использование: node n8n-fixerr.js +const fs = require('fs'); +let w = JSON.parse(fs.readFileSync(process.argv[2])); +if (Array.isArray(w)) w = w[0]; +w.settings = w.settings || {}; +w.settings.errorWorkflow = process.argv[3]; +fs.writeFileSync(process.argv[2], JSON.stringify(w)); +console.log('OK ' + (w.name || w.id) + ' -> errorWorkflow=' + process.argv[3]); diff --git a/deploy/orbita-commands.workflow.json b/deploy/orbita-commands.workflow.json new file mode 100644 index 0000000..4de2de6 --- /dev/null +++ b/deploy/orbita-commands.workflow.json @@ -0,0 +1,96 @@ +[ + { + "id": "orbitaCmds01", + "name": "orbita-commands", + "active": false, + "isArchived": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "orbita-cmd", + "responseMode": "onReceived", + "responseData": "={{ { \"ok\": true } }}", + "options": {} + }, + "id": "wh-orbita-0001", + "name": "WH Orbita", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [-360, 0], + "webhookId": "orbita-cmd-webhook" + }, + { + "parameters": { + "jsCode": "// orbita-commands :: /status /alarms /gps -> sendPhoto (QuickChart) + caption\n// Вход: Telegram update (webhook body). Выход: { chatId, photoUrl, caption, skip }\n// Реальные данные Orbita monitor-api (публичный HTTPS, демо-логин).\n\nconst upd = ($input.first().json.body) || $input.first().json || {};\nconst msg = upd.message || upd.edited_message || upd.channel_post || {};\nconst chatId = msg.chat && msg.chat.id;\nconst textRaw = String(msg.text || '').trim();\n\n// Нет текста / нет chat — тихо выходим (напр. callback/иные апдейты)\nif (!chatId || !textRaw) {\n return [{ json: { skip: true, reason: 'no chat/text' } }];\n}\n\n// команда: первое слово, срезаем @botname\nlet cmd = textRaw.split(/\\s+/)[0].toLowerCase();\ncmd = cmd.replace(/@[a-z0-9_]+$/i, '');\n\nconst API = 'https://monitor.servaki.online';\nconst http = this.helpers.httpRequest;\n\n// ---- helpers ----\nconst pad = n => String(n).padStart(2, '0');\nfunction mskNow() {\n const d = new Date(Date.now() + 3 * 3600 * 1000);\n return pad(d.getUTCDate()) + '.' + pad(d.getUTCMonth() + 1) + ' ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ' МСК';\n}\nfunction qc(config) {\n // QuickChart GET, короткий URL не нужен — Telegram сам скачает\n const c = encodeURIComponent(JSON.stringify(config));\n return 'https://quickchart.io/chart?w=500&h=300&bkg=white&c=' + c;\n}\n\n// ---- справка ----\nif (!['/status', '/alarms', '/gps'].includes(cmd)) {\n const help = 'Орбита · команды\\n' +\n '/status — сводка системы\\n' +\n '/alarms — активные тревоги\\n' +\n '/gps — статус автопарка';\n const banner = 'https://quickchart.io/chart?w=500&h=200&bkg=white&c=' +\n encodeURIComponent(JSON.stringify({ type: 'bar', data: { labels: ['status', 'alarms', 'gps'], datasets: [{ label: 'Orbita', data: [1, 1, 1] }] }, options: { plugins: { title: { display: true, text: 'Servaki Orbita — /status /alarms /gps' } }, legend: { display: false } } }));\n return [{ json: { skip: false, chatId, photoUrl: banner, caption: help, parseMode: 'HTML' } }];\n}\n\n// ---- получаем реальные данные Orbita ----\nlet entities = [], events = [], dataOk = true;\ntry {\n const login = await http({ method: 'POST', url: API + '/api/login', body: { userId: 'demo' }, json: true, timeout: 8000 });\n const token = login && login.token;\n const auth = { Authorization: 'Bearer ' + token };\n entities = await http({ method: 'GET', url: API + '/api/entities', headers: auth, json: true, timeout: 8000 }) || [];\n events = await http({ method: 'GET', url: API + '/api/events', headers: auth, json: true, timeout: 8000 }) || [];\n} catch (e) {\n dataOk = false;\n}\nif (!Array.isArray(entities)) entities = [];\nif (!Array.isArray(events)) events = [];\n\nconst dataNote = dataOk ? '' : '\\n⚠️ данные monitor-api недоступны — показаны 0';\n\nlet photoUrl = '', caption = '';\n\nif (cmd === '/status') {\n const total = entities.length;\n const online = entities.filter(e => e.online).length;\n const offline = total - online;\n const net = entities.filter(e => e.type === 'network');\n const uplink = net.reduce((s, e) => s + ((e.telemetry && e.telemetry.uplink) || 0), 0);\n const clients = net.reduce((s, e) => s + ((e.telemetry && e.telemetry.clients) || 0), 0);\n photoUrl = qc({ type: 'doughnut', data: { labels: ['Онлайн', 'Офлайн'], datasets: [{ data: [online, offline], backgroundColor: ['#22c55e', '#ef4444'] }] }, options: { plugins: { title: { display: true, text: 'Orbita · статус объектов' } } } });\n caption = '📊 Статус системы\\n' +\n 'Объектов: ' + total + ' (онлайн ' + online + ' / офлайн ' + offline + ')\\n' +\n 'Сетевых узлов: ' + net.length + '\\n' +\n 'Клиентов на узлах: ' + clients + '\\n' +\n 'Суммарный аплинк: ' + uplink + ' Мбит/с\\n' +\n '🕒 ' + mskNow() + dataNote;\n} else if (cmd === '/alarms') {\n const crit = events.filter(e => e.severity === 'critical');\n const warn = events.filter(e => e.severity === 'warning');\n const info = events.filter(e => e.severity === 'info');\n const recent = events.slice(0, 3).map(e => {\n const s = e.severity === 'critical' ? '🔴' : (e.severity === 'warning' ? '🟡' : '🔵');\n return s + ' ' + (e.text || e.type || '') + ' · ' + (e.callsign || e.entityId || '');\n }).join('\\n') || 'нет событий';\n photoUrl = qc({ type: 'bar', data: { labels: ['Critical', 'Warning', 'Info'], datasets: [{ label: 'события', data: [crit.length, warn.length, info.length], backgroundColor: ['#ef4444', '#f59e0b', '#3b82f6'] }] }, options: { plugins: { title: { display: true, text: 'Orbita · тревоги' } }, legend: { display: false } } });\n caption = '🚨 Тревоги\\n' +\n '🔴 critical: ' + crit.length + ' 🟡 warning: ' + warn.length + ' 🔵 info: ' + info.length + '\\n' +\n 'Последние:\\n' + recent + '\\n' +\n '🕒 ' + mskNow() + dataNote;\n} else if (cmd === '/gps') {\n const fleet = entities.filter(e => e.type === 'gps' || e.type === 'vehicle' || e.type === 'transport');\n const pool = fleet.length ? fleet : entities; // если нет gps-типа — берём все объекты\n const moving = pool.filter(e => e.online && e.telemetry && (e.telemetry.speed || 0) > 3).length;\n const online = pool.filter(e => e.online).length;\n const parked = online - moving;\n const offline = pool.length - online;\n photoUrl = qc({ type: 'doughnut', data: { labels: ['В движении', 'Стоянка', 'Офлайн'], datasets: [{ data: [moving, parked, offline], backgroundColor: ['#22c55e', '#3b82f6', '#9ca3af'] }] }, options: { plugins: { title: { display: true, text: 'Orbita · автопарк' } } } });\n caption = '🚗 Автопарк / GPS\\n' +\n 'Всего: ' + pool.length + '\\n' +\n '🟢 в движении: ' + moving + '\\n' +\n '🔵 на стоянке: ' + parked + '\\n' +\n '⚪ офлайн: ' + offline + '\\n' +\n '🕒 ' + mskNow() + dataNote;\n}\n\nreturn [{ json: { skip: false, chatId, photoUrl, caption, parseMode: 'HTML' } }];" + }, + "id": "route-orbita-0003", + "name": "Route+Fetch", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-140, -60] + }, + { + "parameters": { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "id": "cond-skip", + "leftValue": "={{ $json.skip }}", + "rightValue": false, + "operator": { "type": "boolean", "operation": "false", "singleValue": true } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-send-0004", + "name": "IF Send?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [100, -60] + }, + { + "parameters": { + "resource": "message", + "operation": "sendPhoto", + "chatId": "={{ $json.chatId }}", + "binaryData": false, + "file": "={{ $json.photoUrl }}", + "additionalFields": { + "caption": "={{ $json.caption }}", + "parse_mode": "={{ $json.parseMode }}" + } + }, + "id": "tg-photo-0005", + "name": "TG SendPhoto", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [340, -140], + "credentials": { + "telegramApi": { "id": "alertsTgCred01", "name": "servaki_online_bot" } + }, + "continueOnFail": true + } + ], + "connections": { + "WH Orbita": { + "main": [ + [ + { "node": "Route+Fetch", "type": "main", "index": 0 } + ] + ] + }, + "Route+Fetch": { + "main": [[{ "node": "IF Send?", "type": "main", "index": 0 }]] + }, + "IF Send?": { + "main": [[{ "node": "TG SendPhoto", "type": "main", "index": 0 }], []] + } + }, + "settings": { "executionOrder": "v1" }, + "pinData": {} + } +] diff --git a/deploy/pcn-receiver.py b/deploy/pcn-receiver.py new file mode 100644 index 0000000..03a58a3 --- /dev/null +++ b/deploy/pcn-receiver.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +""" +ПЦН-приёмник (ru3): Surgard MLR2-DG / Contact ID (:5111) + SIA DC-09 (:5112). +Декодирует account + код события, публикует в mosquitto (raw MQTT 3.1.1, stdlib-only) +топик guard//event JSON. + +ПЕРВАЯ рабочая версия — минимальный, но живой приёмник. +TODO: полная валидация SIA DC-09 CRC/шифрование; edge-cases Contact ID checksum. +""" +import asyncio +import json +import os +import re +import socket +import struct +import tempfile +import threading +import time +import logging + +# ---- Конфиг ---- +MQTT_HOST = "10.50.0.48" # mosquitto vm-mts1 по туннелю awg-mesh +MQTT_PORT = 1883 +MQTT_KEEPALIVE = 60 +SURGARD_PORT = 5111 +SIA_PORT = 5112 +CLIENT_ID = "pcn-ru3" + +# ---- Дисковый спул (буферизация при обрыве связи с брокером) ---- +SPOOL_DIR = "/var/spool/pcn" +SPOOL_FILE = os.path.join(SPOOL_DIR, "queue.jsonl") +SPOOL_MAX_BYTES = 100 * 1024 * 1024 # 100 МБ — лимит спула +SPOOL_TRIM_BYTES = 80 * 1024 * 1024 # при ротации ужимаем до 80 МБ (дропаем старейшие) +DRAIN_INTERVAL = 5 # секунд между попытками до-досыла +DRAIN_BATCH = 200 # сколько сообщений максимум за один проход + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +log = logging.getLogger("pcn") + +# ---- Соответствие SIA <-> Surgard Contact ID (из src/data/security.ts) ---- +SIA_TO_CID = { + "BA": "E130", "FA": "E110", "PA": "E120", "HA": "E121", "TA": "E137", + "OP": "E401", "CL": "R401", "AT": "E301", "AR": "R301", "BT": "E302", + "YC": "E354", "NL": "E381", +} +CID_TO_SIA = {v: k for k, v in SIA_TO_CID.items()} + + +# ============================================================ +# Минимальный raw-MQTT publisher (3.1.1), без внешних зависимостей +# ============================================================ +def _mqtt_remaining_length(n: int) -> bytes: + out = bytearray() + while True: + b = n % 128 + n //= 128 + if n > 0: + b |= 0x80 + out.append(b) + if n == 0: + break + return bytes(out) + + +def _mqtt_str(s: str) -> bytes: + b = s.encode("utf-8") + return struct.pack("!H", len(b)) + b + + +class MqttPublisher: + """Синхронный публикатор в отдельном потоке-неблокирующий он не нужен: + вызываем из executor, чтобы не блокировать asyncio-loop.""" + + def __init__(self, host, port, client_id, keepalive=60): + self.host = host + self.port = port + self.client_id = client_id + self.keepalive = keepalive + self.sock = None + # publish() и дренаж исполняются в разных потоках executor'а — сериализуем сокет. + self._lock = threading.Lock() + + def _connect(self): + s = socket.create_connection((self.host, self.port), timeout=8) + # CONNECT packet + payload = _mqtt_str(self.client_id) + var_header = _mqtt_str("MQTT") + bytes([0x04, 0x02]) + struct.pack("!H", self.keepalive) + body = var_header + payload + pkt = bytes([0x10]) + _mqtt_remaining_length(len(body)) + body + s.sendall(pkt) + # CONNACK (4 bytes) + resp = s.recv(4) + if len(resp) < 4 or resp[0] != 0x20 or resp[3] != 0x00: + s.close() + raise ConnectionError(f"MQTT CONNACK failed: {resp!r}") + self.sock = s + + def publish(self, topic, payload, qos=0): + with self._lock: + if self.sock is None: + self._connect() + try: + self._publish(topic, payload) + except (OSError, ConnectionError) as e: + # reconnect once + log.warning("MQTT publish err (%s), reconnecting", e) + try: + if self.sock: + self.sock.close() + except OSError: + pass + self.sock = None + self._connect() + self._publish(topic, payload) + + def _publish(self, topic, payload): + body = _mqtt_str(topic) + payload.encode("utf-8") + pkt = bytes([0x30]) + _mqtt_remaining_length(len(body)) + body + self.sock.sendall(pkt) + + +_pub = MqttPublisher(MQTT_HOST, MQTT_PORT, CLIENT_ID, MQTT_KEEPALIVE) + + +# ============================================================ +# Дисковый спул: append+fsync при обрыве, дренаж при восстановлении +# ============================================================ +def _spool_ensure(): + try: + os.makedirs(SPOOL_DIR, exist_ok=True) + except OSError as e: + log.error("spool mkdir err: %s", e) + + +def _spool_append(topic, payload): + """Атомарно (append+fsync) дописать одно сообщение в очередь. + Строка формата: {"topic":..,"payload":..}\n""" + _spool_ensure() + line = json.dumps({"topic": topic, "payload": payload}, ensure_ascii=False) + "\n" + try: + _spool_rotate_if_needed() + with open(SPOOL_FILE, "a", encoding="utf-8") as f: + f.write(line) + f.flush() + os.fsync(f.fileno()) + log.warning("SPOOLED %s (queue on disk)", topic) + except OSError as e: + log.error("spool append err: %s (СООБЩЕНИЕ ПОТЕРЯНО)", e) + + +def _spool_rotate_if_needed(): + """Ограничение размера: если спул > SPOOL_MAX, дропаем старейшие строки + до SPOOL_TRIM (сохраняем хвост = самые свежие события).""" + try: + if not os.path.exists(SPOOL_FILE): + return + size = os.path.getsize(SPOOL_FILE) + if size <= SPOOL_MAX_BYTES: + return + log.warning("spool rotate: %d байт > лимит, ужимаю до %d", size, SPOOL_TRIM_BYTES) + with open(SPOOL_FILE, "rb") as f: + f.seek(max(0, size - SPOOL_TRIM_BYTES)) + f.readline() # выравниваемся на границу строки + tail = f.read() + tmp = SPOOL_FILE + ".tmp" + with open(tmp, "wb") as f: + f.write(tail) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, SPOOL_FILE) + except OSError as e: + log.error("spool rotate err: %s", e) + + +def _drain_once(): + """Синхронно (в executor): попытаться до-дослать спул. + Читает весь файл, публикует по одному; при первой ошибке — сохраняет + непосланный остаток обратно (атомарно) и выходит. Возвращает кол-во посланных.""" + if not os.path.exists(SPOOL_FILE) or os.path.getsize(SPOOL_FILE) == 0: + return 0 + sent = 0 + remainder = None + try: + with open(SPOOL_FILE, "r", encoding="utf-8") as f: + lines = f.readlines() + except OSError as e: + log.error("drain read err: %s", e) + return 0 + for i, line in enumerate(lines[:DRAIN_BATCH]): + line = line.strip() + if not line: + sent += 1 + continue + try: + rec = json.loads(line) + _pub.publish(rec["topic"], rec["payload"]) + sent += 1 + except (OSError, ConnectionError) as e: + log.warning("drain: брокер снова недоступен (%s), стоп на %d", e, i) + remainder = lines[i:] + break + except Exception as e: + log.error("drain: битая строка спула, дроп: %s", e) + sent += 1 + if remainder is None: + remainder = lines[DRAIN_BATCH:] + # Перезаписываем спул оставшимся хвостом атомарно + try: + if remainder: + fd, tmp = tempfile.mkstemp(dir=SPOOL_DIR, prefix="q.", suffix=".tmp") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.writelines(remainder) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, SPOOL_FILE) + else: + os.remove(SPOOL_FILE) + except OSError as e: + log.error("drain rewrite err: %s", e) + if sent: + log.info("DRAINED %d из спула, осталось %d", sent, len(remainder)) + return sent + + +async def drain_loop(): + """Фоновая задача: раз в DRAIN_INTERVAL секунд пытается до-дослать спул.""" + loop = asyncio.get_event_loop() + while True: + await asyncio.sleep(DRAIN_INTERVAL) + try: + if os.path.exists(SPOOL_FILE) and os.path.getsize(SPOOL_FILE) > 0: + await loop.run_in_executor(None, _drain_once) + except Exception as e: + log.error("drain_loop err: %s", e) + + +async def publish_event(account, proto, cid, sia, zone, raw): + doc = { + "account": account, + "proto": proto, + "cid": cid, + "sia": sia, + "zone": zone, + "raw": raw, + "ts": int(time.time()), + } + topic = f"guard/{account}/event" + payload = json.dumps(doc, ensure_ascii=False) + loop = asyncio.get_event_loop() + try: + await loop.run_in_executor(None, _pub.publish, topic, payload) + log.info("PUB %s %s", topic, payload) + except Exception as e: + # Брокер/туннель недоступны — НЕ теряем: спуливаем на диск. + log.error("MQTT publish failed: %s -> спул", e) + await loop.run_in_executor(None, _spool_append, topic, payload) + + +# ============================================================ +# Разбор Contact ID кода -> (cid, sia) +# ============================================================ +def cid_to_pair(qualifier, event3): + """qualifier '1'=new/E , '3'=restore/R ; event3 = 3 цифры (130,110,...)""" + prefix = "R" if qualifier == "3" else "E" + cid = f"{prefix}{event3}" + sia = CID_TO_SIA.get(cid, "") + return cid, sia + + +# ============================================================ +# Surgard MLR2-DG / Contact ID приёмник (:5111) +# ============================================================ +# Форматы, которые пытаемся распознать: +# 1) Sur-Gard 5-2-1: "5BB 18 18 " +# напр. "5011 18 1042 18 130 01 002" +# 2) ADM-CID Contact ID: " 18 " +# где MT=18(DTMF)/98, Q=1/3, XYZ=код, GG=group/partition, CCC=zone/user +SURGARD_ACK = b"\x06" # приёмник подтверждает событие символом ACK + + +def parse_surgard(line: str): + line = line.strip() + if not line: + return None + toks = line.split() + # Попытка: найти шаблон Contact ID где-то в строке. + # Ищем последовательность: 18 ... либо явные поля. + # Нормализуем: убираем ведущий "5xx" код линии Sur-Gard если есть. + # Универсальный разбор по токенам: + # ищем токен "18" (Contact ID message type), после него qual+code. + account = None + cid = None + sia = None + zone = None + + # Вариант A: пробельно-разделённые поля (тестовый формат) + # "5011 18 1042 18 130 01 002" + # idx: 0=lineprefix 1=18 2=account 3=18 4=code 5=group 6=zone + if len(toks) >= 7 and toks[1] == "18" and toks[3] == "18": + account = toks[2] + code = toks[4] # 130 + # qualifier может идти как отдельная 1/3 или быть слит; тут по группе. + # В классике group '01'/'02' — event; берём E по умолчанию. + qual = "1" + cid, sia = cid_to_pair(qual, code.zfill(3)[:3]) + zone = toks[6] + return account, cid, sia, zone + + # Вариант B: слитный Contact ID "ACCT 18 Q XYZ GG CCC" + # напр. "1042 18 1 130 01 002" + if len(toks) >= 6 and toks[1] in ("18", "98"): + account = toks[0] + qual = toks[2] + code = toks[3] + cid, sia = cid_to_pair(qual, code.zfill(3)[:3]) + zone = toks[-1] + return account, cid, sia, zone + + # Вариант C: полностью слитная ADM-CID строка "10421813001002" + # ACCT(4) 18 Q(1) XYZ(3) GG(2) CCC(3) + m = re.match(r"^(\d{3,4})18([13])(\d{3})(\d{2})(\d{3})$", line.replace(" ", "")) + if m: + account = m.group(1) + qual = m.group(2) + code = m.group(3) + zone = m.group(5) + cid, sia = cid_to_pair(qual, code) + return account, cid, sia, zone + + # Не распознали — вернём хотя бы raw с пустыми полями (account=UNKNOWN) + return None + + +async def handle_surgard(reader, writer): + peer = writer.get_extra_info("peername") + log.info("Surgard conn from %s", peer) + try: + while True: + data = await asyncio.wait_for(reader.readline(), timeout=120) + if not data: + break + raw = data.decode("latin-1", errors="replace").rstrip("\r\n") + if not raw: + continue + log.info("Surgard raw: %r", raw) + parsed = parse_surgard(raw) + if parsed: + account, cid, sia, zone = parsed + await publish_event(account, "surgard", cid, sia, zone, raw) + else: + log.warning("Surgard: unparsed %r -> publishing raw under UNKNOWN", raw) + await publish_event("UNKNOWN", "surgard", "", "", None, raw) + # ACK приёмника + writer.write(SURGARD_ACK) + await writer.drain() + except asyncio.TimeoutError: + log.info("Surgard %s idle timeout", peer) + except Exception as e: + log.error("Surgard handler err: %s", e) + finally: + try: + writer.close() + except Exception: + pass + log.info("Surgard conn closed %s", peer) + + +# ============================================================ +# SIA DC-09 приёмник (:5112) +# ============================================================ +# Кадр DC-09 (упрощённо): +# \x0A "" R L # [] \x0D +# id: "SIA-DCS" | "ADM-CID" | "NULL" | ... +# data для ADM-CID: |< contact-id ...> ; для SIA-DCS: |Nri1/BA01] +# Ответ ACK: \x0A "ACK" R.. L.. #acct [] \x0D +LF = 0x0A +CR = 0x0D + + +def _dc09_crc(msg: bytes) -> int: + """CRC-16 (polynomial 0x8005, reflected) как в DC-09 (для ACK).""" + crc = 0 + for b in msg: + crc ^= b + for _ in range(8): + if crc & 1: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc & 0xFFFF + + +def parse_sia(frame: str): + """frame — содержимое между LF и CR (без обрамляющих). + Возвращает (account, cid, sia, zone, seq, id_token).""" + account = None + cid = None + sia = None + zone = None + seq = "0000" + id_token = "" + + # id в кавычках + m_id = re.search(r'"([A-Z\-]+)"', frame) + if m_id: + id_token = m_id.group(1) + # seq — 4 цифры сразу после закрывающей кавычки id + m_seq = re.search(r'"[A-Z\-]+"(\d{4})', frame) + if m_seq: + seq = m_seq.group(1) + # account: #ACCT + m_acct = re.search(r"#([0-9A-Fa-f]+)", frame) + if m_acct: + account = m_acct.group(1) + + # payload между [ ... ] (у SIA-DCS) или после последнего | + # SIA-DCS data: |Nri1/BA01] -> code=BA zone=01 + m_sia = re.search(r"/([A-Z]{2})(\d{1,3})", frame) + if m_sia: + sia = m_sia.group(1) + zone = m_sia.group(2) + cid = SIA_TO_CID.get(sia, "") + else: + # ADM-CID внутри DC-09: |1 XYZ GG CCC| или похожее + m_cid = re.search(r"\|(\d)?\s?(\d{3})\s?(\d{2})?\s?(\d{2,3})?", frame) + if m_cid: + qual = m_cid.group(1) or "1" + code = m_cid.group(2) + zone = m_cid.group(4) + prefix = "R" if qual == "3" else "E" + cid = f"{prefix}{code}" + sia = CID_TO_SIA.get(cid, "") + + return account, cid, sia, zone, seq, id_token + + +def build_sia_ack(seq: str, account: str) -> bytes: + """Строим минимальный DC-09 ACK-кадр.""" + ts = time.strftime("_%H:%M:%S,%m-%d-%Y", time.gmtime()) + acct = account or "0" + body = f'"ACK"{seq}R0L0#{acct}[]{ts}' + b = body.encode("ascii", errors="replace") + crc = _dc09_crc(b) + length = len(b) + frame = f'\n{crc:04X}{length:04X}{body}\r' + return frame.encode("ascii", errors="replace") + + +async def handle_sia(reader, writer): + peer = writer.get_extra_info("peername") + log.info("SIA conn from %s", peer) + try: + while True: + # читаем до CR (0x0D) — конец кадра DC-09 + data = await asyncio.wait_for(reader.readuntil(b"\r"), timeout=120) + if not data: + break + raw = data.decode("latin-1", errors="replace") + log.info("SIA raw: %r", raw) + # вырезаем содержимое между LF и CR + inner = raw + if inner and inner[0] == "\n": + inner = inner[1:] + inner = inner.rstrip("\r") + account, cid, sia, zone, seq, id_token = parse_sia(inner) + if account is None: + account = "UNKNOWN" + await publish_event(account, "sia", cid, sia, zone, raw.strip()) + # ACK-кадр + try: + ack = build_sia_ack(seq, account if account != "UNKNOWN" else "0") + writer.write(ack) + await writer.drain() + except Exception as e: + log.warning("SIA ACK build/send err: %s", e) + except asyncio.IncompleteReadError: + log.info("SIA %s incomplete/closed", peer) + except asyncio.TimeoutError: + log.info("SIA %s idle timeout", peer) + except Exception as e: + log.error("SIA handler err: %s", e) + finally: + try: + writer.close() + except Exception: + pass + log.info("SIA conn closed %s", peer) + + +# ============================================================ +async def main(): + srv_sur = await asyncio.start_server(handle_surgard, "0.0.0.0", SURGARD_PORT) + srv_sia = await asyncio.start_server(handle_sia, "0.0.0.0", SIA_PORT) + _spool_ensure() + drain = asyncio.ensure_future(drain_loop()) + log.info("PCN receiver up: Surgard :%d, SIA DC-09 :%d -> mqtt %s:%d (spool %s)", + SURGARD_PORT, SIA_PORT, MQTT_HOST, MQTT_PORT, SPOOL_FILE) + async with srv_sur, srv_sia: + await asyncio.gather(srv_sur.serve_forever(), srv_sia.serve_forever(), drain) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/deploy/pcn-receiver.service b/deploy/pcn-receiver.service new file mode 100644 index 0000000..ec16468 --- /dev/null +++ b/deploy/pcn-receiver.service @@ -0,0 +1,16 @@ +[Unit] +Description=PCN Receiver (Surgard :5111 + SIA DC-09 :5112) -> mosquitto vm-mts1 +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /opt/pcn-receiver/pcn-receiver.py +Restart=always +RestartSec=3 +User=root +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/deploy/pcn.b64 b/deploy/pcn.b64 new file mode 100644 index 0000000..15dd7af --- /dev/null +++ b/deploy/pcn.b64 @@ -0,0 +1 @@ +IyEvdXNyL2Jpbi9lbnYgcHl0aG9uMwoiIiIK0J/QptCdLdC/0YDQuNGR0LzQvdC40LogKHJ1Myk6IFN1cmdhcmQgTUxSMi1ERyAvIENvbnRhY3QgSUQgKDo1MTExKSArIFNJQSBEQy0wOSAoOjUxMTIpLgrQlNC10LrQvtC00LjRgNGD0LXRgiBhY2NvdW50ICsg0LrQvtC0INGB0L7QsdGL0YLQuNGPLCDQv9GD0LHQu9C40LrRg9C10YIg0LIgbW9zcXVpdHRvIChyYXcgTVFUVCAzLjEuMSwgc3RkbGliLW9ubHkpCtGC0L7Qv9C40LogZ3VhcmQvPGFjY291bnQ+L2V2ZW50IEpTT04uCgrQn9CV0KDQktCQ0K8g0YDQsNCx0L7Rh9Cw0Y8g0LLQtdGA0YHQuNGPIOKAlCDQvNC40L3QuNC80LDQu9GM0L3Ri9C5LCDQvdC+INC20LjQstC+0Lkg0L/RgNC40ZHQvNC90LjQui4KVE9ETzog0L/QvtC70L3QsNGPINCy0LDQu9C40LTQsNGG0LjRjyBTSUEgREMtMDkgQ1JDL9GI0LjRhNGA0L7QstCw0L3QuNC1OyBlZGdlLWNhc2VzIENvbnRhY3QgSUQgY2hlY2tzdW0uCiIiIgppbXBvcnQgYXN5bmNpbwppbXBvcnQganNvbgppbXBvcnQgcmUKaW1wb3J0IHNvY2tldAppbXBvcnQgc3RydWN0CmltcG9ydCB0aW1lCmltcG9ydCBsb2dnaW5nCgojIC0tLS0g0JrQvtC90YTQuNCzIC0tLS0KTVFUVF9IT1NUID0gIjEwLjUwLjAuNDgiICAgIyBtb3NxdWl0dG8gdm0tbXRzMSDQv9C+INGC0YPQvdC90LXQu9GOIGF3Zy1tZXNoCk1RVFRfUE9SVCA9IDE4ODMKTVFUVF9LRUVQQUxJVkUgPSA2MApTVVJHQVJEX1BPUlQgPSA1MTExClNJQV9QT1JUID0gNTExMgpDTElFTlRfSUQgPSAicGNuLXJ1MyIKCmxvZ2dpbmcuYmFzaWNDb25maWcoCiAgICBsZXZlbD1sb2dnaW5nLklORk8sCiAgICBmb3JtYXQ9IiUoYXNjdGltZSlzIFslKGxldmVsbmFtZSlzXSAlKG1lc3NhZ2UpcyIsCikKbG9nID0gbG9nZ2luZy5nZXRMb2dnZXIoInBjbiIpCgojIC0tLS0g0KHQvtC+0YLQstC10YLRgdGC0LLQuNC1IFNJQSA8LT4gU3VyZ2FyZCBDb250YWN0IElEICjQuNC3IHNyYy9kYXRhL3NlY3VyaXR5LnRzKSAtLS0tClNJQV9UT19DSUQgPSB7CiAgICAiQkEiOiAiRTEzMCIsICJGQSI6ICJFMTEwIiwgIlBBIjogIkUxMjAiLCAiSEEiOiAiRTEyMSIsICJUQSI6ICJFMTM3IiwKICAgICJPUCI6ICJFNDAxIiwgIkNMIjogIlI0MDEiLCAiQVQiOiAiRTMwMSIsICJBUiI6ICJSMzAxIiwgIkJUIjogIkUzMDIiLAogICAgIllDIjogIkUzNTQiLCAiTkwiOiAiRTM4MSIsCn0KQ0lEX1RPX1NJQSA9IHt2OiBrIGZvciBrLCB2IGluIFNJQV9UT19DSUQuaXRlbXMoKX0KCgojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQojICDQnNC40L3QuNC80LDQu9GM0L3Ri9C5IHJhdy1NUVRUIHB1Ymxpc2hlciAoMy4xLjEpLCDQsdC10Lcg0LLQvdC10YjQvdC40YUg0LfQsNCy0LjRgdC40LzQvtGB0YLQtdC5CiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CmRlZiBfbXF0dF9yZW1haW5pbmdfbGVuZ3RoKG46IGludCkgLT4gYnl0ZXM6CiAgICBvdXQgPSBieXRlYXJyYXkoKQogICAgd2hpbGUgVHJ1ZToKICAgICAgICBiID0gbiAlIDEyOAogICAgICAgIG4gLy89IDEyOAogICAgICAgIGlmIG4gPiAwOgogICAgICAgICAgICBiIHw9IDB4ODAKICAgICAgICBvdXQuYXBwZW5kKGIpCiAgICAgICAgaWYgbiA9PSAwOgogICAgICAgICAgICBicmVhawogICAgcmV0dXJuIGJ5dGVzKG91dCkKCgpkZWYgX21xdHRfc3RyKHM6IHN0cikgLT4gYnl0ZXM6CiAgICBiID0gcy5lbmNvZGUoInV0Zi04IikKICAgIHJldHVybiBzdHJ1Y3QucGFjaygiIUgiLCBsZW4oYikpICsgYgoKCmNsYXNzIE1xdHRQdWJsaXNoZXI6CiAgICAiIiLQodC40L3RhdGA0L7QvdC90YvQuSDQv9GD0LHQu9C40LrQsNGC0L7RgCDQsiDQvtGC0LTQtdC70YzQvdC+0Lwg0L/QvtGC0L7QutC1LdC90LXQsdC70L7QutC40YDRg9GO0YnQuNC5INC+0L0g0L3QtSDQvdGD0LbQtdC9OgogICAg0LLRi9C30YvQstCw0LXQvCDQuNC3IGV4ZWN1dG9yLCDRh9GC0L7QsdGLINC90LUg0LHQu9C+0LrQuNGA0L7QstCw0YLRjCBhc3luY2lvLWxvb3AuIiIiCgogICAgZGVmIF9faW5pdF9fKHNlbGYsIGhvc3QsIHBvcnQsIGNsaWVudF9pZCwga2VlcGFsaXZlPTYwKToKICAgICAgICBzZWxmLmhvc3QgPSBob3N0CiAgICAgICAgc2VsZi5wb3J0ID0gcG9ydAogICAgICAgIHNlbGYuY2xpZW50X2lkID0gY2xpZW50X2lkCiAgICAgICAgc2VsZi5rZWVwYWxpdmUgPSBrZWVwYWxpdmUKICAgICAgICBzZWxmLnNvY2sgPSBOb25lCgogICAgZGVmIF9jb25uZWN0KHNlbGYpOgogICAgICAgIHMgPSBzb2NrZXQuY3JlYXRlX2Nvbm5lY3Rpb24oKHNlbGYuaG9zdCwgc2VsZi5wb3J0KSwgdGltZW91dD04KQogICAgICAgICMgQ09OTkVDVCBwYWNrZXQKICAgICAgICBwYXlsb2FkID0gX21xdHRfc3RyKHNlbGYuY2xpZW50X2lkKQogICAgICAgIHZhcl9oZWFkZXIgPSBfbXF0dF9zdHIoIk1RVFQiKSArIGJ5dGVzKFsweDA0LCAweDAyXSkgKyBzdHJ1Y3QucGFjaygiIUgiLCBzZWxmLmtlZXBhbGl2ZSkKICAgICAgICBib2R5ID0gdmFyX2hlYWRlciArIHBheWxvYWQKICAgICAgICBwa3QgPSBieXRlcyhbMHgxMF0pICsgX21xdHRfcmVtYWluaW5nX2xlbmd0aChsZW4oYm9keSkpICsgYm9keQogICAgICAgIHMuc2VuZGFsbChwa3QpCiAgICAgICAgIyBDT05OQUNLICg0IGJ5dGVzKQogICAgICAgIHJlc3AgPSBzLnJlY3YoNCkKICAgICAgICBpZiBsZW4ocmVzcCkgPCA0IG9yIHJlc3BbMF0gIT0gMHgyMCBvciByZXNwWzNdICE9IDB4MDA6CiAgICAgICAgICAgIHMuY2xvc2UoKQogICAgICAgICAgICByYWlzZSBDb25uZWN0aW9uRXJyb3IoZiJNUVRUIENPTk5BQ0sgZmFpbGVkOiB7cmVzcCFyfSIpCiAgICAgICAgc2VsZi5zb2NrID0gcwoKICAgIGRlZiBwdWJsaXNoKHNlbGYsIHRvcGljLCBwYXlsb2FkLCBxb3M9MCk6CiAgICAgICAgaWYgc2VsZi5zb2NrIGlzIE5vbmU6CiAgICAgICAgICAgIHNlbGYuX2Nvbm5lY3QoKQogICAgICAgIHRyeToKICAgICAgICAgICAgc2VsZi5fcHVibGlzaCh0b3BpYywgcGF5bG9hZCkKICAgICAgICBleGNlcHQgKE9TRXJyb3IsIENvbm5lY3Rpb25FcnJvcikgYXMgZToKICAgICAgICAgICAgIyByZWNvbm5lY3Qgb25jZQogICAgICAgICAgICBsb2cud2FybmluZygiTVFUVCBwdWJsaXNoIGVyciAoJXMpLCByZWNvbm5lY3RpbmciLCBlKQogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBpZiBzZWxmLnNvY2s6CiAgICAgICAgICAgICAgICAgICAgc2VsZi5zb2NrLmNsb3NlKCkKICAgICAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgICAgICAgICBwYXNzCiAgICAgICAgICAgIHNlbGYuc29jayA9IE5vbmUKICAgICAgICAgICAgc2VsZi5fY29ubmVjdCgpCiAgICAgICAgICAgIHNlbGYuX3B1Ymxpc2godG9waWMsIHBheWxvYWQpCgogICAgZGVmIF9wdWJsaXNoKHNlbGYsIHRvcGljLCBwYXlsb2FkKToKICAgICAgICBib2R5ID0gX21xdHRfc3RyKHRvcGljKSArIHBheWxvYWQuZW5jb2RlKCJ1dGYtOCIpCiAgICAgICAgcGt0ID0gYnl0ZXMoWzB4MzBdKSArIF9tcXR0X3JlbWFpbmluZ19sZW5ndGgobGVuKGJvZHkpKSArIGJvZHkKICAgICAgICBzZWxmLnNvY2suc2VuZGFsbChwa3QpCgoKX3B1YiA9IE1xdHRQdWJsaXNoZXIoTVFUVF9IT1NULCBNUVRUX1BPUlQsIENMSUVOVF9JRCwgTVFUVF9LRUVQQUxJVkUpCgoKYXN5bmMgZGVmIHB1Ymxpc2hfZXZlbnQoYWNjb3VudCwgcHJvdG8sIGNpZCwgc2lhLCB6b25lLCByYXcpOgogICAgZG9jID0gewogICAgICAgICJhY2NvdW50IjogYWNjb3VudCwKICAgICAgICAicHJvdG8iOiBwcm90bywKICAgICAgICAiY2lkIjogY2lkLAogICAgICAgICJzaWEiOiBzaWEsCiAgICAgICAgInpvbmUiOiB6b25lLAogICAgICAgICJyYXciOiByYXcsCiAgICAgICAgInRzIjogaW50KHRpbWUudGltZSgpKSwKICAgIH0KICAgIHRvcGljID0gZiJndWFyZC97YWNjb3VudH0vZXZlbnQiCiAgICBwYXlsb2FkID0ganNvbi5kdW1wcyhkb2MsIGVuc3VyZV9hc2NpaT1GYWxzZSkKICAgIGxvb3AgPSBhc3luY2lvLmdldF9ldmVudF9sb29wKCkKICAgIHRyeToKICAgICAgICBhd2FpdCBsb29wLnJ1bl9pbl9leGVjdXRvcihOb25lLCBfcHViLnB1Ymxpc2gsIHRvcGljLCBwYXlsb2FkKQogICAgICAgIGxvZy5pbmZvKCJQVUIgJXMgJXMiLCB0b3BpYywgcGF5bG9hZCkKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICBsb2cuZXJyb3IoIk1RVFQgcHVibGlzaCBmYWlsZWQ6ICVzIiwgZSkKCgojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQojICDQoNCw0LfQsdC+0YAgQ29udGFjdCBJRCDQutC+0LTQsCAtPiAoY2lkLCBzaWEpCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CmRlZiBjaWRfdG9fcGFpcihxdWFsaWZpZXIsIGV2ZW50Myk6CiAgICAiIiJxdWFsaWZpZXIgJzEnPW5ldy9FICwgJzMnPXJlc3RvcmUvUiA7IGV2ZW50MyA9IDMg0YbQuNGE0YDRiyAoMTMwLDExMCwuLi4pIiIiCiAgICBwcmVmaXggPSAiUiIgaWYgcXVhbGlmaWVyID09ICIzIiBlbHNlICJFIgogICAgY2lkID0gZiJ7cHJlZml4fXtldmVudDN9IgogICAgc2lhID0gQ0lEX1RPX1NJQS5nZXQoY2lkLCAiIikKICAgIHJldHVybiBjaWQsIHNpYQoKCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CiMgIFN1cmdhcmQgTUxSMi1ERyAvIENvbnRhY3QgSUQg0L/RgNC40ZHQvNC90LjQuiAoOjUxMTEpCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CiMg0KTQvtGA0LzQsNGC0YssINC60L7RgtC+0YDRi9C1INC/0YvRgtCw0LXQvNGB0Y8g0YDQsNGB0L/QvtC30L3QsNGC0Yw6CiMgIDEpIFN1ci1HYXJkIDUtMi0xOiAiNUJCIDE4IDxhY2NvdW50PiAxOCA8Q0lEPiA8cXVhbD4gPHpvbmU+IgojICAgICDQvdCw0L/RgC4gIjUwMTEgMTggMTA0MiAxOCAxMzAgMDEgMDAyIgojICAyKSBBRE0tQ0lEIENvbnRhY3QgSUQ6ICI8QUNDVD4gMTggPE1UPiA8UT4gPFhZWj4gPEdHPiA8Q0NDPiIKIyAgICAg0LPQtNC1IE1UPTE4KERUTUYpLzk4LCBRPTEvMywgWFlaPdC60L7QtCwgR0c9Z3JvdXAvcGFydGl0aW9uLCBDQ0M9em9uZS91c2VyClNVUkdBUkRfQUNLID0gYiJceDA2IiAgIyDQv9GA0LjRkdC80L3QuNC6INC/0L7QtNGC0LLQtdGA0LbQtNCw0LXRgiDRgdC+0LHRi9GC0LjQtSDRgdC40LzQstC+0LvQvtC8IEFDSwoKCmRlZiBwYXJzZV9zdXJnYXJkKGxpbmU6IHN0cik6CiAgICBsaW5lID0gbGluZS5zdHJpcCgpCiAgICBpZiBub3QgbGluZToKICAgICAgICByZXR1cm4gTm9uZQogICAgdG9rcyA9IGxpbmUuc3BsaXQoKQogICAgIyDQn9C+0L/Ri9GC0LrQsDog0L3QsNC50YLQuCDRiNCw0LHQu9C+0L0gQ29udGFjdCBJRCDQs9C00LUt0YLQviDQsiDRgdGC0YDQvtC60LUuCiAgICAjINCY0YnQtdC8INC/0L7RgdC70LXQtNC+0LLQsNGC0LXQu9GM0L3QvtGB0YLRjDogPGFjY3Q+IDE4IDxxPjx4eXo+IC4uLiDQu9C40LHQviDRj9Cy0L3Ri9C1INC/0L7Qu9GPLgogICAgIyDQndC+0YDQvNCw0LvQuNC30YPQtdC8OiDRg9Cx0LjRgNCw0LXQvCDQstC10LTRg9GJ0LjQuSAiNXh4IiDQutC+0LQg0LvQuNC90LjQuCBTdXItR2FyZCDQtdGB0LvQuCDQtdGB0YLRjC4KICAgICMg0KPQvdC40LLQtdGA0YHQsNC70YzQvdGL0Lkg0YDQsNC30LHQvtGAINC/0L4g0YLQvtC60LXQvdCw0Lw6CiAgICAjICAg0LjRidC10Lwg0YLQvtC60LXQvSAiMTgiIChDb250YWN0IElEIG1lc3NhZ2UgdHlwZSksINC/0L7RgdC70LUg0L3QtdCz0L4gcXVhbCtjb2RlLgogICAgYWNjb3VudCA9IE5vbmUKICAgIGNpZCA9IE5vbmUKICAgIHNpYSA9IE5vbmUKICAgIHpvbmUgPSBOb25lCgogICAgIyDQktCw0YDQuNCw0L3RgiBBOiDQv9GA0L7QsdC10LvRjNC90L4t0YDQsNC30LTQtdC70ZHQvdC90YvQtSDQv9C+0LvRjyAo0YLQtdGB0YLQvtCy0YvQuSDRhNC+0YDQvNCw0YIpCiAgICAjICAgIjUwMTEgMTggMTA0MiAxOCAxMzAgMDEgMDAyIgogICAgIyAgIGlkeDogMD1saW5lcHJlZml4IDE9MTggMj1hY2NvdW50IDM9MTggND1jb2RlIDU9Z3JvdXAgNj16b25lCiAgICBpZiBsZW4odG9rcykgPj0gNyBhbmQgdG9rc1sxXSA9PSAiMTgiIGFuZCB0b2tzWzNdID09ICIxOCI6CiAgICAgICAgYWNjb3VudCA9IHRva3NbMl0KICAgICAgICBjb2RlID0gdG9rc1s0XSAgICAgICAgICAgICAgIyAxMzAKICAgICAgICAjIHF1YWxpZmllciDQvNC+0LbQtdGCINC40LTRgtC4INC60LDQuiDQvtGC0LTQtdC70YzQvdCw0Y8gMS8zINC40LvQuCDQsdGL0YLRjCDRgdC70LjRgjsg0YLRg9GCINC/0L4g0LPRgNGD0L/Qv9C1LgogICAgICAgICMg0JIg0LrQu9Cw0YHRgdC40LrQtSBncm91cCAnMDEnLycwMicg4oCUIGV2ZW50OyDQsdC10YDRkdC8IEUg0L/QviDRg9C80L7Qu9GH0LDQvdC40Y4uCiAgICAgICAgcXVhbCA9ICIxIgogICAgICAgIGNpZCwgc2lhID0gY2lkX3RvX3BhaXIocXVhbCwgY29kZS56ZmlsbCgzKVs6M10pCiAgICAgICAgem9uZSA9IHRva3NbNl0KICAgICAgICByZXR1cm4gYWNjb3VudCwgY2lkLCBzaWEsIHpvbmUKCiAgICAjINCS0LDRgNC40LDQvdGCIEI6INGB0LvQuNGC0L3Ri9C5IENvbnRhY3QgSUQgIkFDQ1QgMTggUSBYWVogR0cgQ0NDIgogICAgIyAgINC90LDQv9GALiAiMTA0MiAxOCAxIDEzMCAwMSAwMDIiCiAgICBpZiBsZW4odG9rcykgPj0gNiBhbmQgdG9rc1sxXSBpbiAoIjE4IiwgIjk4Iik6CiAgICAgICAgYWNjb3VudCA9IHRva3NbMF0KICAgICAgICBxdWFsID0gdG9rc1syXQogICAgICAgIGNvZGUgPSB0b2tzWzNdCiAgICAgICAgY2lkLCBzaWEgPSBjaWRfdG9fcGFpcihxdWFsLCBjb2RlLnpmaWxsKDMpWzozXSkKICAgICAgICB6b25lID0gdG9rc1stMV0KICAgICAgICByZXR1cm4gYWNjb3VudCwgY2lkLCBzaWEsIHpvbmUKCiAgICAjINCS0LDRgNC40LDQvdGCIEM6INC/0L7Qu9C90L7RgdGC0YzRjiDRgdC70LjRgtC90LDRjyBBRE0tQ0lEINGB0YLRgNC+0LrQsCAiMTA0MjE4MTMwMDEwMDIiCiAgICAjICAgQUNDVCg0KSAxOCBRKDEpIFhZWigzKSBHRygyKSBDQ0MoMykKICAgIG0gPSByZS5tYXRjaChyIl4oXGR7Myw0fSkxOChbMTNdKShcZHszfSkoXGR7Mn0pKFxkezN9KSQiLCBsaW5lLnJlcGxhY2UoIiAiLCAiIikpCiAgICBpZiBtOgogICAgICAgIGFjY291bnQgPSBtLmdyb3VwKDEpCiAgICAgICAgcXVhbCA9IG0uZ3JvdXAoMikKICAgICAgICBjb2RlID0gbS5ncm91cCgzKQogICAgICAgIHpvbmUgPSBtLmdyb3VwKDUpCiAgICAgICAgY2lkLCBzaWEgPSBjaWRfdG9fcGFpcihxdWFsLCBjb2RlKQogICAgICAgIHJldHVybiBhY2NvdW50LCBjaWQsIHNpYSwgem9uZQoKICAgICMg0J3QtSDRgNCw0YHQv9C+0LfQvdCw0LvQuCDigJQg0LLQtdGA0L3RkdC8INGF0L7RgtGPINCx0YsgcmF3INGBINC/0YPRgdGC0YvQvNC4INC/0L7Qu9GP0LzQuCAoYWNjb3VudD1VTktOT1dOKQogICAgcmV0dXJuIE5vbmUKCgphc3luYyBkZWYgaGFuZGxlX3N1cmdhcmQocmVhZGVyLCB3cml0ZXIpOgogICAgcGVlciA9IHdyaXRlci5nZXRfZXh0cmFfaW5mbygicGVlcm5hbWUiKQogICAgbG9nLmluZm8oIlN1cmdhcmQgY29ubiBmcm9tICVzIiwgcGVlcikKICAgIHRyeToKICAgICAgICB3aGlsZSBUcnVlOgogICAgICAgICAgICBkYXRhID0gYXdhaXQgYXN5bmNpby53YWl0X2ZvcihyZWFkZXIucmVhZGxpbmUoKSwgdGltZW91dD0xMjApCiAgICAgICAgICAgIGlmIG5vdCBkYXRhOgogICAgICAgICAgICAgICAgYnJlYWsKICAgICAgICAgICAgcmF3ID0gZGF0YS5kZWNvZGUoImxhdGluLTEiLCBlcnJvcnM9InJlcGxhY2UiKS5yc3RyaXAoIlxyXG4iKQogICAgICAgICAgICBpZiBub3QgcmF3OgogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgbG9nLmluZm8oIlN1cmdhcmQgcmF3OiAlciIsIHJhdykKICAgICAgICAgICAgcGFyc2VkID0gcGFyc2Vfc3VyZ2FyZChyYXcpCiAgICAgICAgICAgIGlmIHBhcnNlZDoKICAgICAgICAgICAgICAgIGFjY291bnQsIGNpZCwgc2lhLCB6b25lID0gcGFyc2VkCiAgICAgICAgICAgICAgICBhd2FpdCBwdWJsaXNoX2V2ZW50KGFjY291bnQsICJzdXJnYXJkIiwgY2lkLCBzaWEsIHpvbmUsIHJhdykKICAgICAgICAgICAgZWxzZToKICAgICAgICAgICAgICAgIGxvZy53YXJuaW5nKCJTdXJnYXJkOiB1bnBhcnNlZCAlciAtPiBwdWJsaXNoaW5nIHJhdyB1bmRlciBVTktOT1dOIiwgcmF3KQogICAgICAgICAgICAgICAgYXdhaXQgcHVibGlzaF9ldmVudCgiVU5LTk9XTiIsICJzdXJnYXJkIiwgIiIsICIiLCBOb25lLCByYXcpCiAgICAgICAgICAgICMgQUNLINC/0YDQuNGR0LzQvdC40LrQsAogICAgICAgICAgICB3cml0ZXIud3JpdGUoU1VSR0FSRF9BQ0spCiAgICAgICAgICAgIGF3YWl0IHdyaXRlci5kcmFpbigpCiAgICBleGNlcHQgYXN5bmNpby5UaW1lb3V0RXJyb3I6CiAgICAgICAgbG9nLmluZm8oIlN1cmdhcmQgJXMgaWRsZSB0aW1lb3V0IiwgcGVlcikKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICBsb2cuZXJyb3IoIlN1cmdhcmQgaGFuZGxlciBlcnI6ICVzIiwgZSkKICAgIGZpbmFsbHk6CiAgICAgICAgdHJ5OgogICAgICAgICAgICB3cml0ZXIuY2xvc2UoKQogICAgICAgIGV4Y2VwdCBFeGNlcHRpb246CiAgICAgICAgICAgIHBhc3MKICAgICAgICBsb2cuaW5mbygiU3VyZ2FyZCBjb25uIGNsb3NlZCAlcyIsIHBlZXIpCgoKIyA9PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0KIyAgU0lBIERDLTA5INC/0YDQuNGR0LzQvdC40LogKDo1MTEyKQojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQojINCa0LDQtNGAIERDLTA5ICjRg9C/0YDQvtGJ0ZHQvdC90L4pOgojICBceDBBIDxDUkM0aGV4PiA8TEVONGhleD4gIjxpZD4iIDxzZXE+IFI8cmN2cj4gTDxsaW5lPiAjPGFjY291bnQ+IFs8ZGF0YT5dIDx0cz4gXHgwRAojICBpZDogIlNJQS1EQ1MiIHwgIkFETS1DSUQiIHwgIk5VTEwiIHwgLi4uCiMgIGRhdGEg0LTQu9GPIEFETS1DSUQ6IHw8IGNvbnRhY3QtaWQgLi4uPiAgOyDQtNC70Y8gU0lBLURDUzogfE5yaTEvQkEwMV0KIyAg0J7RgtCy0LXRgiBBQ0s6IFx4MEEgPENSQz4gPExFTj4gIkFDSyIgPHNlcT4gUi4uIEwuLiAjYWNjdCBbXSA8dHM+IFx4MEQKTEYgPSAweDBBCkNSID0gMHgwRAoKCmRlZiBfZGMwOV9jcmMobXNnOiBieXRlcykgLT4gaW50OgogICAgIiIiQ1JDLTE2IChwb2x5bm9taWFsIDB4ODAwNSwgcmVmbGVjdGVkKSDQutCw0Log0LIgREMtMDkgKNC00LvRjyBBQ0spLiIiIgogICAgY3JjID0gMAogICAgZm9yIGIgaW4gbXNnOgogICAgICAgIGNyYyBePSBiCiAgICAgICAgZm9yIF8gaW4gcmFuZ2UoOCk6CiAgICAgICAgICAgIGlmIGNyYyAmIDE6CiAgICAgICAgICAgICAgICBjcmMgPSAoY3JjID4+IDEpIF4gMHhBMDAxCiAgICAgICAgICAgIGVsc2U6CiAgICAgICAgICAgICAgICBjcmMgPj49IDEKICAgIHJldHVybiBjcmMgJiAweEZGRkYKCgpkZWYgcGFyc2Vfc2lhKGZyYW1lOiBzdHIpOgogICAgIiIiZnJhbWUg4oCUINGB0L7QtNC10YDQttC40LzQvtC1INC80LXQttC00YMgTEYg0LggQ1IgKNCx0LXQtyDQvtCx0YDQsNC80LvRj9GO0YnQuNGFKS4KICAgINCS0L7Qt9Cy0YDQsNGJ0LDQtdGCIChhY2NvdW50LCBjaWQsIHNpYSwgem9uZSwgc2VxLCBpZF90b2tlbikuIiIiCiAgICBhY2NvdW50ID0gTm9uZQogICAgY2lkID0gTm9uZQogICAgc2lhID0gTm9uZQogICAgem9uZSA9IE5vbmUKICAgIHNlcSA9ICIwMDAwIgogICAgaWRfdG9rZW4gPSAiIgoKICAgICMgaWQg0LIg0LrQsNCy0YvRh9C60LDRhQogICAgbV9pZCA9IHJlLnNlYXJjaChyJyIoW0EtWlwtXSspIicsIGZyYW1lKQogICAgaWYgbV9pZDoKICAgICAgICBpZF90b2tlbiA9IG1faWQuZ3JvdXAoMSkKICAgICMgc2VxIOKAlCA0INGG0LjRhNGA0Ysg0YHRgNCw0LfRgyDQv9C+0YHQu9C1INC30LDQutGA0YvQstCw0Y7RidC10Lkg0LrQsNCy0YvRh9C60LggaWQKICAgIG1fc2VxID0gcmUuc2VhcmNoKHInIltBLVpcLV0rIihcZHs0fSknLCBmcmFtZSkKICAgIGlmIG1fc2VxOgogICAgICAgIHNlcSA9IG1fc2VxLmdyb3VwKDEpCiAgICAjIGFjY291bnQ6ICNBQ0NUCiAgICBtX2FjY3QgPSByZS5zZWFyY2gociIjKFswLTlBLUZhLWZdKykiLCBmcmFtZSkKICAgIGlmIG1fYWNjdDoKICAgICAgICBhY2NvdW50ID0gbV9hY2N0Lmdyb3VwKDEpCgogICAgIyBwYXlsb2FkINC80LXQttC00YMgWyAuLi4gXSAo0YMgU0lBLURDUykg0LjQu9C4INC/0L7RgdC70LUg0L/QvtGB0LvQtdC00L3QtdCz0L4gfAogICAgIyBTSUEtRENTIGRhdGE6IHxOcmkxL0JBMDFdICAtPiBjb2RlPUJBIHpvbmU9MDEKICAgIG1fc2lhID0gcmUuc2VhcmNoKHIiLyhbQS1aXXsyfSkoXGR7MSwzfSkiLCBmcmFtZSkKICAgIGlmIG1fc2lhOgogICAgICAgIHNpYSA9IG1fc2lhLmdyb3VwKDEpCiAgICAgICAgem9uZSA9IG1fc2lhLmdyb3VwKDIpCiAgICAgICAgY2lkID0gU0lBX1RPX0NJRC5nZXQoc2lhLCAiIikKICAgIGVsc2U6CiAgICAgICAgIyBBRE0tQ0lEINCy0L3Rg9GC0YDQuCBEQy0wOTogfDEgWFlaIEdHIENDQ3wg0LjQu9C4INC/0L7RhdC+0LbQtdC1CiAgICAgICAgbV9jaWQgPSByZS5zZWFyY2gociJcfChcZCk/XHM/KFxkezN9KVxzPyhcZHsyfSk/XHM/KFxkezIsM30pPyIsIGZyYW1lKQogICAgICAgIGlmIG1fY2lkOgogICAgICAgICAgICBxdWFsID0gbV9jaWQuZ3JvdXAoMSkgb3IgIjEiCiAgICAgICAgICAgIGNvZGUgPSBtX2NpZC5ncm91cCgyKQogICAgICAgICAgICB6b25lID0gbV9jaWQuZ3JvdXAoNCkKICAgICAgICAgICAgcHJlZml4ID0gIlIiIGlmIHF1YWwgPT0gIjMiIGVsc2UgIkUiCiAgICAgICAgICAgIGNpZCA9IGYie3ByZWZpeH17Y29kZX0iCiAgICAgICAgICAgIHNpYSA9IENJRF9UT19TSUEuZ2V0KGNpZCwgIiIpCgogICAgcmV0dXJuIGFjY291bnQsIGNpZCwgc2lhLCB6b25lLCBzZXEsIGlkX3Rva2VuCgoKZGVmIGJ1aWxkX3NpYV9hY2soc2VxOiBzdHIsIGFjY291bnQ6IHN0cikgLT4gYnl0ZXM6CiAgICAiIiLQodGC0YDQvtC40Lwg0LzQuNC90LjQvNCw0LvRjNC90YvQuSBEQy0wOSBBQ0st0LrQsNC00YAuIiIiCiAgICB0cyA9IHRpbWUuc3RyZnRpbWUoIl8lSDolTTolUywlbS0lZC0lWSIsIHRpbWUuZ210aW1lKCkpCiAgICBhY2N0ID0gYWNjb3VudCBvciAiMCIKICAgIGJvZHkgPSBmJyJBQ0sie3NlcX1SMEwwI3thY2N0fVtde3RzfScKICAgIGIgPSBib2R5LmVuY29kZSgiYXNjaWkiLCBlcnJvcnM9InJlcGxhY2UiKQogICAgY3JjID0gX2RjMDlfY3JjKGIpCiAgICBsZW5ndGggPSBsZW4oYikKICAgIGZyYW1lID0gZidcbntjcmM6MDRYfXtsZW5ndGg6MDRYfXtib2R5fVxyJwogICAgcmV0dXJuIGZyYW1lLmVuY29kZSgiYXNjaWkiLCBlcnJvcnM9InJlcGxhY2UiKQoKCmFzeW5jIGRlZiBoYW5kbGVfc2lhKHJlYWRlciwgd3JpdGVyKToKICAgIHBlZXIgPSB3cml0ZXIuZ2V0X2V4dHJhX2luZm8oInBlZXJuYW1lIikKICAgIGxvZy5pbmZvKCJTSUEgY29ubiBmcm9tICVzIiwgcGVlcikKICAgIHRyeToKICAgICAgICB3aGlsZSBUcnVlOgogICAgICAgICAgICAjINGH0LjRgtCw0LXQvCDQtNC+IENSICgweDBEKSDigJQg0LrQvtC90LXRhiDQutCw0LTRgNCwIERDLTA5CiAgICAgICAgICAgIGRhdGEgPSBhd2FpdCBhc3luY2lvLndhaXRfZm9yKHJlYWRlci5yZWFkdW50aWwoYiJcciIpLCB0aW1lb3V0PTEyMCkKICAgICAgICAgICAgaWYgbm90IGRhdGE6CiAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICByYXcgPSBkYXRhLmRlY29kZSgibGF0aW4tMSIsIGVycm9ycz0icmVwbGFjZSIpCiAgICAgICAgICAgIGxvZy5pbmZvKCJTSUEgcmF3OiAlciIsIHJhdykKICAgICAgICAgICAgIyDQstGL0YDQtdC30LDQtdC8INGB0L7QtNC10YDQttC40LzQvtC1INC80LXQttC00YMgTEYg0LggQ1IKICAgICAgICAgICAgaW5uZXIgPSByYXcKICAgICAgICAgICAgaWYgaW5uZXIgYW5kIGlubmVyWzBdID09ICJcbiI6CiAgICAgICAgICAgICAgICBpbm5lciA9IGlubmVyWzE6XQogICAgICAgICAgICBpbm5lciA9IGlubmVyLnJzdHJpcCgiXHIiKQogICAgICAgICAgICBhY2NvdW50LCBjaWQsIHNpYSwgem9uZSwgc2VxLCBpZF90b2tlbiA9IHBhcnNlX3NpYShpbm5lcikKICAgICAgICAgICAgaWYgYWNjb3VudCBpcyBOb25lOgogICAgICAgICAgICAgICAgYWNjb3VudCA9ICJVTktOT1dOIgogICAgICAgICAgICBhd2FpdCBwdWJsaXNoX2V2ZW50KGFjY291bnQsICJzaWEiLCBjaWQsIHNpYSwgem9uZSwgcmF3LnN0cmlwKCkpCiAgICAgICAgICAgICMgQUNLLdC60LDQtNGACiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIGFjayA9IGJ1aWxkX3NpYV9hY2soc2VxLCBhY2NvdW50IGlmIGFjY291bnQgIT0gIlVOS05PV04iIGVsc2UgIjAiKQogICAgICAgICAgICAgICAgd3JpdGVyLndyaXRlKGFjaykKICAgICAgICAgICAgICAgIGF3YWl0IHdyaXRlci5kcmFpbigpCiAgICAgICAgICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICAgICAgICAgIGxvZy53YXJuaW5nKCJTSUEgQUNLIGJ1aWxkL3NlbmQgZXJyOiAlcyIsIGUpCiAgICBleGNlcHQgYXN5bmNpby5JbmNvbXBsZXRlUmVhZEVycm9yOgogICAgICAgIGxvZy5pbmZvKCJTSUEgJXMgaW5jb21wbGV0ZS9jbG9zZWQiLCBwZWVyKQogICAgZXhjZXB0IGFzeW5jaW8uVGltZW91dEVycm9yOgogICAgICAgIGxvZy5pbmZvKCJTSUEgJXMgaWRsZSB0aW1lb3V0IiwgcGVlcikKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICBsb2cuZXJyb3IoIlNJQSBoYW5kbGVyIGVycjogJXMiLCBlKQogICAgZmluYWxseToKICAgICAgICB0cnk6CiAgICAgICAgICAgIHdyaXRlci5jbG9zZSgpCiAgICAgICAgZXhjZXB0IEV4Y2VwdGlvbjoKICAgICAgICAgICAgcGFzcwogICAgICAgIGxvZy5pbmZvKCJTSUEgY29ubiBjbG9zZWQgJXMiLCBwZWVyKQoKCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CmFzeW5jIGRlZiBtYWluKCk6CiAgICBzcnZfc3VyID0gYXdhaXQgYXN5bmNpby5zdGFydF9zZXJ2ZXIoaGFuZGxlX3N1cmdhcmQsICIwLjAuMC4wIiwgU1VSR0FSRF9QT1JUKQogICAgc3J2X3NpYSA9IGF3YWl0IGFzeW5jaW8uc3RhcnRfc2VydmVyKGhhbmRsZV9zaWEsICIwLjAuMC4wIiwgU0lBX1BPUlQpCiAgICBsb2cuaW5mbygiUENOIHJlY2VpdmVyIHVwOiBTdXJnYXJkIDolZCwgU0lBIERDLTA5IDolZCAtPiBtcXR0ICVzOiVkIiwKICAgICAgICAgICAgIFNVUkdBUkRfUE9SVCwgU0lBX1BPUlQsIE1RVFRfSE9TVCwgTVFUVF9QT1JUKQogICAgYXN5bmMgd2l0aCBzcnZfc3VyLCBzcnZfc2lhOgogICAgICAgIGF3YWl0IGFzeW5jaW8uZ2F0aGVyKHNydl9zdXIuc2VydmVfZm9yZXZlcigpLCBzcnZfc2lhLnNlcnZlX2ZvcmV2ZXIoKSkKCgppZiBfX25hbWVfXyA9PSAiX19tYWluX18iOgogICAgdHJ5OgogICAgICAgIGFzeW5jaW8ucnVuKG1haW4oKSkKICAgIGV4Y2VwdCBLZXlib2FyZEludGVycnVwdDoKICAgICAgICBwYXNzCg== \ No newline at end of file diff --git a/deploy/ru2-mqtt_publisher.py b/deploy/ru2-mqtt_publisher.py new file mode 100644 index 0000000..2ada2c7 --- /dev/null +++ b/deploy/ru2-mqtt_publisher.py @@ -0,0 +1,296 @@ +"""Публикация телеметрии в MQTT с авто-обнаружением Home Assistant. + +Каждый трекер (по IMEI) автоматически появляется в Home Assistant как +устройство со своими сущностями: напряжение питания/АКБ, температуры, +вскрытие шкафа, входы, уровень GSM, код события, позиция на карте. +HA дальше используется как движок сцен/автоматизаций и агро-панель. + +БУФЕРИЗАЦИЯ: при обрыве связи с брокером (туннель/mosquitto down) сообщения +не теряются — пишутся в дисковый спул /opt/monitor-flex-ru2/spool/queue.jsonl +(том, переживает пересборку), фоновый поток до-досылает при восстановлении. +""" +import json +import logging +import os +import tempfile +import threading +import time + +import paho.mqtt.client as mqtt + +from . import config + +log = logging.getLogger("flex.mqtt") + +# ---- Дисковый спул ---- +SPOOL_DIR = os.getenv("SPOOL_DIR", "/app/spool") +SPOOL_FILE = os.path.join(SPOOL_DIR, "queue.jsonl") +SPOOL_MAX_BYTES = 100 * 1024 * 1024 # 100 МБ — лимит спула +SPOOL_TRIM_BYTES = 80 * 1024 * 1024 # при ротации ужимаем до 80 МБ (дропаем старейшие) +DRAIN_INTERVAL = 5 # секунд между попытками до-досыла +DRAIN_BATCH = 500 # сколько сообщений максимум за один проход + +# Числовые сенсоры: (ключ, имя, ед.изм., device_class, иконка) +# Набор подобран под START S-2013: напряжение основного питания снимается с AIN3 +# (см. README, профиль S-2013), pwr_int = «системное питание» ≈ АКБ модуля. +# Температуры приходят с RS-485/Bluetooth-датчиков (если настроены в конфигураторе). +SENSORS = [ + ("pwr_ext", "Напряжение питания (AIN3→Ug+)", "V", "voltage", None), + ("pwr_int", "Системное питание (АКБ)", "V", "voltage", None), + ("temp1", "Температура 1", "°C", "temperature", None), + ("temp2", "Температура 2", "°C", "temperature", None), + ("gsm", "Уровень GSM", None, None, "mdi:signal"), + ("event_code", "Код события", None, None, "mdi:alert-circle-outline"), +] + +# Бинарные датчики: (ключ, имя, device_class) +# У S-2013 две дискретные линии (IN1, IN2); AIN3 занят под измерение напряжения. +# Датчик двери/вскрытия шкафа подключается к IN1 или IN2. +BINARY_SENSORS = [ + ("in1", "Дверь шкафа (IN1)", "door"), + ("in2", "Охрана (IN2)", "safety"), + ("chasis_open", "Вскрытие корпуса", "tamper"), +] + + +class MqttPublisher: + def __init__(self): + self.client = mqtt.Client(client_id=config.MQTT_CLIENT_ID, protocol=mqtt.MQTTv311) + if config.MQTT_USER: + self.client.username_pw_set(config.MQTT_USER, config.MQTT_PASSWORD) + self._announced = set() + self._spool_lock = threading.Lock() + self._connected = False + self.client.on_connect = self._on_connect + self.client.on_disconnect = self._on_disconnect + self._drain_stop = threading.Event() + self._drain_thread = None + + # ---- paho callbacks: следим за реальным состоянием соединения ---- + def _on_connect(self, client, userdata, flags, rc): + self._connected = (rc == 0) + if rc == 0: + log.info("MQTT connected (on_connect)") + else: + log.warning("MQTT connect rc=%s", rc) + + def _on_disconnect(self, client, userdata, rc): + self._connected = False + if rc != 0: + log.warning("MQTT disconnected rc=%s (буферизую на диск)", rc) + + def connect(self): + # connect_async + loop_start: не блокируемся, авто-reconnect средствами paho + self.client.reconnect_delay_set(min_delay=1, max_delay=30) + self.client.connect_async(config.MQTT_HOST, config.MQTT_PORT, keepalive=60) + self.client.loop_start() + self._spool_ensure() + self._drain_thread = threading.Thread(target=self._drain_loop, daemon=True) + self._drain_thread.start() + log.info("MQTT loop запущен -> %s:%s (spool %s)", + config.MQTT_HOST, config.MQTT_PORT, SPOOL_FILE) + + def _state_topic(self, imei): + return f"{config.MQTT_BASE_TOPIC}/{imei}/state" + + def _device_block(self, imei): + return { + "identifiers": [f"navtelecom_{imei}"], + "name": f"Щитовой шкаф {imei}", + "manufacturer": "Navtelecom", + "model": "FLEX tracker", + } + + def _announce(self, imei): + """Опубликовать конфиги обнаружения HA (один раз на устройство).""" + if imei in self._announced: + return + state_topic = self._state_topic(imei) + dev = self._device_block(imei) + pfx = config.HA_DISCOVERY_PREFIX + + for key, name, unit, dclass, icon in SENSORS: + uid = f"navtelecom_{imei}_{key}" + cfg = { + "name": name, + "unique_id": uid, + "state_topic": state_topic, + "value_template": f"{{{{ value_json.{key} }}}}", + "device": dev, + } + if unit: + cfg["unit_of_measurement"] = unit + if dclass: + cfg["device_class"] = dclass + if dclass in ("voltage", "temperature"): + cfg["state_class"] = "measurement" + if icon: + cfg["icon"] = icon + self._pub(f"{pfx}/sensor/{uid}/config", cfg, retain=True) + + for key, name, dclass in BINARY_SENSORS: + uid = f"navtelecom_{imei}_{key}" + cfg = { + "name": name, + "unique_id": uid, + "state_topic": state_topic, + "value_template": f"{{{{ value_json.{key} }}}}", + "payload_on": 1, + "payload_off": 0, + "device_class": dclass, + "device": dev, + } + self._pub(f"{pfx}/binary_sensor/{uid}/config", cfg, retain=True) + + # Позиция на карте (device_tracker по GPS-атрибутам) + uid = f"navtelecom_{imei}_tracker" + self._pub(f"{pfx}/device_tracker/{uid}/config", { + "name": f"Позиция {imei}", + "unique_id": uid, + "json_attributes_topic": state_topic, + "source_type": "gps", + "device": dev, + }, retain=True) + + self._announced.add(imei) + log.info("HA discovery опубликован для %s", imei) + + def publish(self, imei, record): + """Опубликовать одну запись телеметрии.""" + if not imei: + return + self._announce(imei) + payload = dict(record) + # device_tracker ждёт latitude/longitude + if "lat" in record and "lon" in record: + payload["latitude"] = record["lat"] + payload["longitude"] = record["lon"] + self._pub(self._state_topic(imei), payload, retain=True) + + def _pub(self, topic, obj, retain=False): + """Публикация с буферизацией: если брокер недоступен — спуливаем на диск.""" + data = json.dumps(obj, ensure_ascii=False) + self._pub_raw(topic, data, retain) + + def _pub_raw(self, topic, data, retain=False): + info = None + if self._connected: + try: + info = self.client.publish(topic, data, retain=retain) + except Exception as e: + log.warning("MQTT publish exc: %s", e) + info = None + if info is None or info.rc != mqtt.MQTT_ERR_SUCCESS: + # Брокер недоступен — не теряем, пишем в дисковый спул. + self._spool_append(topic, data, retain) + + # ============================================================ + # Дисковый спул: append+fsync при обрыве, дренаж при восстановлении + # ============================================================ + def _spool_ensure(self): + try: + os.makedirs(SPOOL_DIR, exist_ok=True) + except OSError as e: + log.error("spool mkdir err: %s", e) + + def _spool_append(self, topic, data, retain): + line = json.dumps({"topic": topic, "payload": data, "retain": bool(retain)}, + ensure_ascii=False) + "\n" + with self._spool_lock: + try: + self._spool_rotate_if_needed() + with open(SPOOL_FILE, "a", encoding="utf-8") as f: + f.write(line) + f.flush() + os.fsync(f.fileno()) + log.warning("SPOOLED %s (брокер недоступен)", topic) + except OSError as e: + log.error("spool append err: %s (СООБЩЕНИЕ ПОТЕРЯНО)", e) + + def _spool_rotate_if_needed(self): + """Ограничение размера: дропаем старейшие строки, сохраняя свежий хвост.""" + try: + if not os.path.exists(SPOOL_FILE): + return + size = os.path.getsize(SPOOL_FILE) + if size <= SPOOL_MAX_BYTES: + return + log.warning("spool rotate: %d байт > лимит, ужимаю до %d", size, SPOOL_TRIM_BYTES) + with open(SPOOL_FILE, "rb") as f: + f.seek(max(0, size - SPOOL_TRIM_BYTES)) + f.readline() + tail = f.read() + tmp = SPOOL_FILE + ".tmp" + with open(tmp, "wb") as f: + f.write(tail) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, SPOOL_FILE) + except OSError as e: + log.error("spool rotate err: %s", e) + + def _drain_once(self): + with self._spool_lock: + if not os.path.exists(SPOOL_FILE) or os.path.getsize(SPOOL_FILE) == 0: + return 0 + try: + with open(SPOOL_FILE, "r", encoding="utf-8") as f: + lines = f.readlines() + except OSError as e: + log.error("drain read err: %s", e) + return 0 + sent = 0 + remainder = None + for i, line in enumerate(lines[:DRAIN_BATCH]): + line = line.strip() + if not line: + sent += 1 + continue + if not self._connected: + remainder = lines[i:] + break + try: + rec = json.loads(line) + info = self.client.publish(rec["topic"], rec["payload"], + retain=rec.get("retain", False)) + if info.rc != mqtt.MQTT_ERR_SUCCESS: + remainder = lines[i:] + break + sent += 1 + except Exception as e: + log.error("drain: битая строка спула, дроп: %s", e) + sent += 1 + if remainder is None: + remainder = lines[DRAIN_BATCH:] + # Перезаписываем спул оставшимся хвостом атомарно + with self._spool_lock: + try: + if remainder: + fd, tmp = tempfile.mkstemp(dir=SPOOL_DIR, prefix="q.", suffix=".tmp") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.writelines(remainder) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, SPOOL_FILE) + else: + if os.path.exists(SPOOL_FILE): + os.remove(SPOOL_FILE) + except OSError as e: + log.error("drain rewrite err: %s", e) + if sent: + log.info("DRAINED %d из спула, осталось %d", sent, len(remainder)) + return sent + + def _drain_loop(self): + while not self._drain_stop.is_set(): + self._drain_stop.wait(DRAIN_INTERVAL) + try: + if os.path.exists(SPOOL_FILE) and os.path.getsize(SPOOL_FILE) > 0: + self._drain_once() + except Exception as e: + log.error("drain_loop err: %s", e) + + def close(self): + self._drain_stop.set() + self.client.loop_stop() + self.client.disconnect() diff --git a/deploy/svc.b64 b/deploy/svc.b64 new file mode 100644 index 0000000..4dff8e4 --- /dev/null +++ b/deploy/svc.b64 @@ -0,0 +1 @@ +W1VuaXRdCkRlc2NyaXB0aW9uPVBDTiBSZWNlaXZlciAoU3VyZ2FyZCA6NTExMSArIFNJQSBEQy0wOSA6NTExMikgLT4gbW9zcXVpdHRvIHZtLW10czEKQWZ0ZXI9bmV0d29yay1vbmxpbmUudGFyZ2V0CldhbnRzPW5ldHdvcmstb25saW5lLnRhcmdldAoKW1NlcnZpY2VdClR5cGU9c2ltcGxlCkV4ZWNTdGFydD0vdXNyL2Jpbi9weXRob24zIC9vcHQvcGNuLXJlY2VpdmVyL3Bjbi1yZWNlaXZlci5weQpSZXN0YXJ0PWFsd2F5cwpSZXN0YXJ0U2VjPTMKVXNlcj1yb290ClN0YW5kYXJkT3V0cHV0PWpvdXJuYWwKU3RhbmRhcmRFcnJvcj1qb3VybmFsCgpbSW5zdGFsbF0KV2FudGVkQnk9bXVsdGktdXNlci50YXJnZXQK \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a0ac534 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +# Лёгкий фронт (для vm-mts1 за Caddy и дубля на офис-Proxmox). +# Тяжёлый слой данных (InfluxDB/Grafana/MQTT/FLEX-сервер) — отдельным compose на Proxmox. +services: + web: + build: . + image: servaki-monitor:latest + container_name: servaki-monitor + restart: unless-stopped + ports: + - "8096:80" # за Caddy (reverse_proxy → :8096), как Heritage diff --git a/index.html b/index.html new file mode 100644 index 0000000..94fa404 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Железный Егорин — Смартпанель + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..67018ae --- /dev/null +++ b/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA: все маршруты → index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Статика с кэшем + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # API-прокси к бэкенду-шлюзу (следующий этап) — раскомментировать при деплое бэка + # location /api/ { proxy_pass http://servaki-monitor-api:4100; } + # location /socket { proxy_pass http://servaki-monitor-api:4100; proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..93dbadf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3411 @@ +{ + "name": "servaki-monitor", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "servaki-monitor", + "version": "0.1.0", + "dependencies": { + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "leaflet": "^1.9.4", + "lucide-react": "^0.400.0", + "maplibre-gl": "^5.24.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "recharts": "^2.12.7", + "tailwind-merge": "^2.4.0", + "zustand": "^4.5.4" + }, + "devDependencies": { + "@types/leaflet": "^1.9.21", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.40", + "tailwindcss": "^3.4.7", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz", + "integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.381", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", + "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.400.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", + "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a953e2e --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "servaki-monitor", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Servaki Monitor — единая смартпанель: Энергомониторинг + Смарт(realty) + GPS-мониторинг (Navtelecom FLEX/NTCB + Wiren Board)", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --host", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "leaflet": "^1.9.4", + "lucide-react": "^0.400.0", + "maplibre-gl": "^5.24.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "recharts": "^2.12.7", + "tailwind-merge": "^2.4.0", + "zustand": "^4.5.4" + }, + "devDependencies": { + "@types/leaflet": "^1.9.21", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.40", + "tailwindcss": "^3.4.7", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prototypes/orbit-gps-mobile.html b/prototypes/orbit-gps-mobile.html new file mode 100644 index 0000000..9f50839 --- /dev/null +++ b/prototypes/orbit-gps-mobile.html @@ -0,0 +1,633 @@ + + + + + + + + + +ORBIT · GPS-мониторинг + + + + + +
+ +
+ + +
+ +
+ + +
+ +
+

Настройки карты

+
3D-здания
+
Подписи объектов
+
+ +
+
В движении
+
Стоянка / на связи
+
Офлайн
+
Тревога
+
+ + +
+
+
+

+ +
+ +
+
Статус
+
Телеметрия
+
Поездки
+
+
+
+ + + +
+
+ + +
+
Объекты
+
+
+ + +
+ + + + + +
+
+ + +
+ + + + +
+ +
+ + + + diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..33c1e81 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,20 @@ +# Шлюз. Адаптер активен только если задан источник; иначе раздел на демо-данных. +PORT=4100 +JWT_SECRET=change-me-in-prod + +# Wiren Board / FLEX-сервер (MQTT). В ingest-стеке = mqtt://mosquitto:1883 +MQTT_URL=mqtt://mosquitto:1883 +MQTT_BASE_TOPIC=navtelecom +# MQTT_USER= +# MQTT_PASS= +# MQTT_PREFIX=/devices + +# Traccar (когда стек развёрнут) +# TRACCAR_URL=http://traccar:8082 +# TRACCAR_TOKEN=... (или TRACCAR_USER / TRACCAR_PASS) + +# InfluxDB (энерго, когда стек развёрнут) +# INFLUX_URL=http://influxdb:8086 +# INFLUX_TOKEN=... +# INFLUX_ORG=servaki +# INFLUX_BUCKET=energy diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..ede4cf8 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,5 @@ +node_modules +.env +.env.* +!.env.example +*.log diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..3a4fc05 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY src ./src +EXPOSE 4100 +CMD ["node", "src/server.js"] diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..f1c4946 --- /dev/null +++ b/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "servaki-monitor-api", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Бэкенд-шлюз Servaki Monitor: адаптеры Traccar / Wiren Board (MQTT) / InfluxDB → единая модель, REST + WebSocket, RBAC, мультиарендность", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "@influxdata/influxdb-client": "^1.35.0", + "cors": "^2.8.5", + "express": "^4.19.2", + "mqtt": "^5.7.0", + "ws": "^8.17.0" + } +} diff --git a/server/src/adapters/influx.js b/server/src/adapters/influx.js new file mode 100644 index 0000000..efd61b8 --- /dev/null +++ b/server/src/adapters/influx.js @@ -0,0 +1,38 @@ +// InfluxDB-адаптер: энерго-ряд (PV/SOC/нагрузка) из бакета. Активен при INFLUX_URL. +import { InfluxDB } from '@influxdata/influxdb-client'; +import { config } from '../config.js'; +import { setEnergySeries } from '../store.js'; + +export function startInflux() { + if (!config.influx) return false; + const q = new InfluxDB({ url: config.influx.url, token: config.influx.token }).getQueryApi(config.influx.org); + const flux = `from(bucket:"${config.influx.bucket}") + |> range(start:-24h) + |> filter(fn:(r)=> r._measurement=="pv" or r._measurement=="soc" or r._measurement=="load") + |> aggregateWindow(every:1h, fn:mean, createEmpty:false)`; + async function poll() { + const rows = {}; + try { + await new Promise((resolve, reject) => { + q.queryRows(flux, { + next: (row, meta) => { + const o = meta.toObject(row); + const t = new Date(o._time).toISOString().slice(11, 16); + rows[t] = rows[t] || { t }; + rows[t][o._measurement] = Math.round(o._value); + }, + error: reject, + complete: resolve, + }); + }); + const series = Object.values(rows); + if (series.length) setEnergySeries(series); + } catch (e) { + console.error('[influx]', e.message); + } + } + poll(); + setInterval(poll, 60000); + console.log('[influx] запросы', config.influx.url); + return true; +} diff --git a/server/src/adapters/mqtt.js b/server/src/adapters/mqtt.js new file mode 100644 index 0000000..8dfe440 --- /dev/null +++ b/server/src/adapters/mqtt.js @@ -0,0 +1,50 @@ +// MQTT-адаптер: приём позиций от FLEX-сервера (navtelecom//state) + +// состояние каналов Wiren Board (/devices//controls/). +import mqtt from 'mqtt'; +import { config } from '../config.js'; +import { addPosition, upsertEntity, updateChannel } from '../store.js'; +import { imeiOwner } from '../tenants.js'; + +const TELE_KEYS = ['pwr_ext', 'pwr_int', 'temp1', 'temp2', 'gsm', 'speed', 'sat', 'event_code']; + +export function startMqtt() { + if (!config.mqtt) return false; + const c = mqtt.connect(config.mqtt.url, { + username: config.mqtt.username, + password: config.mqtt.password, + reconnectPeriod: 5000, + }); + c.on('connect', () => { + console.log('[mqtt] подключён', config.mqtt.url); + c.subscribe('navtelecom/+/state'); + c.subscribe(`${config.mqtt.prefix}/+/controls/+`); + }); + c.on('message', (topic, buf) => { + try { + let m = topic.match(/^navtelecom\/([^/]+)\/state$/); + if (m) { + const imei = m[1]; + const p = JSON.parse(buf.toString()); + const lat = p.lat ?? p.latitude; + const lon = p.lon ?? p.longitude; + const telemetry = {}; + TELE_KEYS.forEach((k) => p[k] != null && (telemetry[k] = Number(p[k]))); + const id = `nt-${imei}`; + const clientId = imeiOwner(imei) || 'unassigned'; + upsertEntity({ id, clientId, name: `Трекер ${imei}`, type: 'transport' }); // тег арендатора по IMEI + if (lat != null && lon != null) addPosition(id, Number(lat), Number(lon), telemetry); + else upsertEntity({ id, online: true, telemetry }); + return; + } + m = topic.match(new RegExp(`${config.mqtt.prefix}/([^/]+)/controls/([^/]+)$`)); + if (m) { + const v = buf.toString(); + updateChannel(`${m[1]}/${m[2]}`, { on: v === '1' || v === 'true', ...(isNaN(+v) ? {} : { level: +v }) }); + } + } catch { + /* malformed payload — ignore */ + } + }); + c.on('error', (e) => console.error('[mqtt]', e.message)); + return true; +} diff --git a/server/src/adapters/traccar.js b/server/src/adapters/traccar.js new file mode 100644 index 0000000..d85309c --- /dev/null +++ b/server/src/adapters/traccar.js @@ -0,0 +1,34 @@ +// Traccar-адаптер: опрос REST /api/devices + /api/positions → объекты/треки. +// Активен при TRACCAR_URL. (Когда стек Traccar развёрнут — данные пойдут сюда.) +import { config } from '../config.js'; +import { addPosition, upsertEntity } from '../store.js'; + +export function startTraccar() { + if (!config.traccar) return false; + const base = config.traccar.url.replace(/\/$/, ''); + const headers = {}; + if (config.traccar.token) headers.Authorization = `Bearer ${config.traccar.token}`; + else if (config.traccar.user) + headers.Authorization = 'Basic ' + Buffer.from(`${config.traccar.user}:${config.traccar.pass}`).toString('base64'); + + const devices = {}; + async function poll() { + try { + const d = await (await fetch(`${base}/api/devices`, { headers })).json(); + d.forEach((x) => { + devices[x.id] = x; + upsertEntity({ id: `tc-${x.id}`, clientId: 'traccar', name: x.name, type: 'transport', online: x.status === 'online' }); + }); + const pos = await (await fetch(`${base}/api/positions`, { headers })).json(); + pos.forEach((p) => + addPosition(`tc-${p.deviceId}`, p.latitude, p.longitude, { speed: Math.round((p.speed || 0) * 1.852), sat: p.attributes?.sat || 0 }, p.fixTime), + ); + } catch (e) { + console.error('[traccar]', e.message); + } + } + poll(); + setInterval(poll, 10000); + console.log('[traccar] опрос', base); + return true; +} diff --git a/server/src/auth.js b/server/src/auth.js new file mode 100644 index 0000000..05cb083 --- /dev/null +++ b/server/src/auth.js @@ -0,0 +1,89 @@ +// RBAC-зеркало фронта + простой HMAC-токен (без внешних зависимостей). +import crypto from 'node:crypto'; +import { config } from './config.js'; + +export const USERS = [ + { + id: 'admin', + name: 'Администратор', + role: 'Полный доступ', + admin: true, + access: { + energy: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + smart: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + gps: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + netinfra: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + security: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + }, + }, + { id: 'pcn-operator', name: 'Дежурный ПЦН', role: 'Оператор пульта', access: { security: { caps: ['view', 'edit', 'reports'], objects: 'all' } } }, + { id: 'energetik-ro', name: 'Сидоров А.', role: 'Энергетик · просмотр', access: { energy: { caps: ['view'], objects: ['cab-07'] } } }, + { id: 'energetik-rw', name: 'Петров В.', role: 'Энергетик · правка', access: { energy: { caps: ['view', 'edit', 'reports'], objects: 'all' } } }, + { id: 'logist', name: 'Кузнецова М.', role: 'Логист транспорта', access: { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } } }, + { id: 'netadmin', name: 'Орлов Д.', role: 'Сетевой инженер', access: { netinfra: { caps: ['view', 'edit', 'reports'], objects: 'all' } } }, + { + id: 'demo', + name: 'Демо-доступ', + role: 'Только просмотр', + access: { + energy: { caps: ['view'], objects: 'all' }, + smart: { caps: ['view'], objects: 'all' }, + gps: { caps: ['view'], objects: 'all' }, + netinfra: { caps: ['view'], objects: 'all' }, + security: { caps: ['view'], objects: 'all' }, + }, + }, +]; + +export const TYPE_SECTION = { transport: 'gps', energy: 'energy', realty: 'smart', network: 'netinfra' }; + +const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // срок жизни сессии — 30 дней +const STATE_TTL_MS = 10 * 60 * 1000; // OAuth state (CSRF) — 10 минут + +export function sign(user) { + const body = Buffer.from(JSON.stringify({ id: user.id, t: Date.now() })).toString('base64url'); + const sig = crypto.createHmac('sha256', config.jwtSecret).update(body).digest('base64url'); + return `${body}.${sig}`; +} + +// Возвращает id пользователя из токена (сам пользователь резолвится в tenants). null если подпись/срок невалидны. +export function verifyId(token) { + if (!token) return null; + const [body, sig] = token.split('.'); + if (!body || !sig) return null; + const exp = crypto.createHmac('sha256', config.jwtSecret).update(body).digest('base64url'); + if (exp !== sig) return null; + try { + const p = JSON.parse(Buffer.from(body, 'base64url').toString()); + if (!p.t || Date.now() - p.t > TOKEN_TTL_MS) return null; // сессия просрочена + return p.id || null; + } catch { + return null; + } +} + +// CSRF-state для OAuth: подписанный короткоживущий токен (stateless, без хранилища сессий) +export function signState() { + const body = Buffer.from(JSON.stringify({ n: crypto.randomBytes(9).toString('hex'), t: Date.now() })).toString('base64url'); + const sig = crypto.createHmac('sha256', config.jwtSecret).update('state:' + body).digest('base64url'); + return `${body}.${sig}`; +} +export function verifyState(state) { + if (!state) return false; + const [body, sig] = String(state).split('.'); + if (!body || !sig) return false; + const exp = crypto.createHmac('sha256', config.jwtSecret).update('state:' + body).digest('base64url'); + if (exp !== sig) return false; + try { + const p = JSON.parse(Buffer.from(body, 'base64url').toString()); + return !!p.t && Date.now() - p.t <= STATE_TTL_MS; + } catch { + return false; + } +} + +export const can = (user, section, cap) => !!user?.access?.[section]?.caps?.includes(cap); +export function isObjectAllowed(user, section, id) { + const o = user?.access?.[section]?.objects; + return o === 'all' || (Array.isArray(o) && o.includes(id)); +} diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..d00e483 --- /dev/null +++ b/server/src/config.js @@ -0,0 +1,38 @@ +// Конфигурация шлюза из окружения. Адаптер активен только если задан его источник; +// иначе раздел работает на демо-данных (fallback). Так шлюз поднимается сразу, +// а реальные источники «зажигаются» по мере готовности их стеков. +const env = process.env; + +export const config = { + port: Number(env.PORT || 4100), + jwtSecret: env.JWT_SECRET || 'dev-secret-change-me', + dataDir: env.DATA_DIR || '/data', // персист арендаторов/привязок устройств + + traccar: env.TRACCAR_URL + ? { url: env.TRACCAR_URL, token: env.TRACCAR_TOKEN || '', user: env.TRACCAR_USER || '', pass: env.TRACCAR_PASS || '' } + : null, + + mqtt: env.MQTT_URL + ? { url: env.MQTT_URL, username: env.MQTT_USER || undefined, password: env.MQTT_PASS || undefined, prefix: env.MQTT_PREFIX || '/devices' } + : null, + + influx: env.INFLUX_URL + ? { url: env.INFLUX_URL, token: env.INFLUX_TOKEN || '', org: env.INFLUX_ORG || '', bucket: env.INFLUX_BUCKET || 'energy' } + : null, + + // Яндекс ID (OAuth). Активен при YANDEX_CLIENT_ID. Регистрация: oauth.yandex.ru. + yandex: env.YANDEX_CLIENT_ID + ? { + clientId: env.YANDEX_CLIENT_ID, + secret: env.YANDEX_CLIENT_SECRET || '', + redirect: env.YANDEX_REDIRECT || 'https://monitor.servaki.online/api/auth/yandex/callback', + admins: (env.YANDEX_ADMINS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), + } + : null, +}; + +export const sources = { + traccar: !!config.traccar, + wirenboard: !!config.mqtt, + influx: !!config.influx, +}; diff --git a/server/src/demo.js b/server/src/demo.js new file mode 100644 index 0000000..04be9ff --- /dev/null +++ b/server/src/demo.js @@ -0,0 +1,77 @@ +// Демо-данные (fallback, когда реальные источники не сконфигурированы). +// Транспорт «двигается» — формируются живые треки, как от реальных устройств. +import { upsertEntity, addPosition, setChannels, updateChannel, addEvent, setEnergySeries, setTrips } from './store.js'; + +export function seedDemo() { + // Энерго-щит (стационар) + upsertEntity({ id: 'cab-07', clientId: 'tempelhoff', name: 'Щит КТП-7', type: 'energy', online: false, lat: 44.08, lon: 43.0, telemetry: { pwr_ext: 0, pwr_int: 3.9, temp: 24 } }); + + // Сетевые узлы mesh2 + const nodes = [ + ['mesh2-hub', 'finik-хаб', 44.072, 43.05, { latency: 1.2, uplink: 940, clients: 38, cpu: 17 }, { ip: '10.200.0.1', role: 'Центральный хаб mesh2' }], + ['mesh2-kn1', 'Keenetic · Склад', 44.064, 43.058, { latency: 3.4, uplink: 310, clients: 12, cpu: 22 }, { ip: '10.200.1.2', role: 'Спица · склад' }], + ['mesh2-kn2', 'Keenetic · Ферма', 44.081, 43.041, { latency: 5.1, uplink: 95, clients: 7, cpu: 19 }, { ip: '10.200.2.2', role: 'Спица · ферма' }], + ]; + nodes.forEach(([id, name, lat, lon, t, meta]) => upsertEntity({ id, clientId: 'tempelhoff', name, type: 'network', online: true, lat, lon, telemetry: t, meta })); + + // Каналы Wiren Board + setChannels([ + { id: 'mr6c_214/K1', device: 'WB-MR6C #214', name: 'Освещение цех', kind: 'relay', on: true }, + { id: 'mr6c_214/K2', device: 'WB-MR6C #214', name: 'Ворота', kind: 'relay', on: false }, + { id: 'mdm3_30/CH1', device: 'WB-MDM3 #30', name: 'Свет офис', kind: 'dimmer', on: true, level: 65 }, + { id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Датчик двери', kind: 'input', on: false }, + ]); + + // Энерго-ряд за сутки + setEnergySeries( + Array.from({ length: 24 }, (_, h) => { + const s = Math.max(0, Math.sin(((h - 6) / 12) * Math.PI)); + return { t: `${String(h).padStart(2, '0')}:00`, pv: Math.round(s * 4200), soc: Math.round(40 + s * 55), load: Math.round(600 + Math.sin((h / 24) * Math.PI * 2) * 250) }; + }), + ); + + // Поездки (журнал) + setTrips([ + { id: 't1', entityId: 'veh-123', start: new Date(Date.now() - 6 * 3.6e6).toISOString(), end: new Date(Date.now() - 5.2 * 3.6e6).toISOString(), from: 'База', to: 'Пятигорск', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41 }, + { id: 't2', entityId: 'veh-123', start: new Date(Date.now() - 2 * 3.6e6).toISOString(), end: new Date(Date.now() - 1.4 * 3.6e6).toISOString(), from: 'Ессентуки', to: 'База', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51 }, + ]); + + addEvent({ id: 'e1', entityId: 'cab-07', callsign: 'Щит КТП-7', severity: 'critical', type: 'power_loss', text: 'Пропадание сетевого питания, переход на ИБП.', ts: new Date().toISOString(), ackRequired: true }); + + // Подвижные объекты + стартовые позиции + const moving = { + 'veh-123': { name: 'ГАЗель-123', lat: 44.05, lon: 43.06, hd: 0.4 }, + 'veh-204': { name: 'МТЗ-82 трактор', lat: 44.02, lon: 43.11, hd: 1.2 }, + }; + Object.entries(moving).forEach(([id, m]) => + upsertEntity({ id, clientId: 'tempelhoff', name: m.name, type: 'transport', online: true, lat: m.lat, lon: m.lon, telemetry: { speed: 0, sat: 10, fuel: 55, voltage: 13.6 } }), + ); + + // Тикер «движения» → живые треки (имитация приёма с устройств) + setInterval(() => { + for (const [id, m] of Object.entries(moving)) { + m.hd += (Math.random() - 0.5) * 0.6; + const speed = id === 'veh-204' ? Math.round(Math.random() * 12) : Math.round(35 + Math.random() * 45); + const step = (speed / 3600) * 0.02; + m.lat += Math.cos(m.hd) * step; + m.lon += Math.sin(m.hd) * step; + addPosition(id, +m.lat.toFixed(5), +m.lon.toFixed(5), { speed, sat: 9 + Math.round(Math.random() * 4), fuel: 50, voltage: 13.6 }); + } + }, 3000); + + // Датчик двери: периодическая смена статуса + событие — демонстрация live-обновления по WS + let door = false; + setInterval(() => { + door = !door; + updateChannel('mr6c_214/IN1', { on: door }); + addEvent({ + id: 'door-' + new Date().toISOString(), + entityId: 'cab-07', + callsign: 'Датчик двери · WB-MR6C #214', + severity: door ? 'warning' : 'info', + type: door ? 'door_open' : 'door_closed', + text: door ? 'Дверь шкафа открыта' : 'Дверь шкафа закрыта', + ts: new Date().toISOString(), + }); + }, 9000); +} diff --git a/server/src/metrics.js b/server/src/metrics.js new file mode 100644 index 0000000..b913a5b --- /dev/null +++ b/server/src/metrics.js @@ -0,0 +1,78 @@ +// Метрики: онлайн-пользователи по разделам, журнал активности, нагрузка. In-memory. +const seen = new Map(); // userId -> { ts, section, name } +const activity = []; // {ts, userId, name, section, action} +let reqTotal = 0; +const reqTimes = []; // метки запросов за последнюю минуту + +const ONLINE_MS = 120000; // онлайн = активность за 2 мин +const wsByUser = new Map(); // userId -> { count, name } + +export function wsConnect(user) { + const e = wsByUser.get(user.id) || { count: 0, name: user.name }; + e.count++; + wsByUser.set(user.id, e); +} +export function wsDisconnect(userId) { + const e = wsByUser.get(userId); + if (e && --e.count <= 0) wsByUser.delete(userId); +} + +export function touch(user, section = '—', action = 'запрос') { + const now = Date.now(); + seen.set(user.id, { ts: now, section, name: user.name }); + reqTotal++; + reqTimes.push(now); + activity.unshift({ ts: new Date(now).toISOString(), userId: user.id, name: user.name, section, action }); + if (activity.length > 800) activity.length = 800; +} + +function onlineSet() { + const now = Date.now(); + const out = new Map(); // id -> { name, section, secAgo } + for (const [id, v] of seen) if (now - v.ts < ONLINE_MS) out.set(id, { name: v.name, section: v.section, secAgo: Math.round((now - v.ts) / 1000) }); + for (const [id, v] of wsByUser) if (!out.has(id)) out.set(id, { name: v.name, section: 'live', secAgo: 0 }); + return out; +} + +export function stats() { + const now = Date.now(); + while (reqTimes.length && reqTimes[0] < now - 60000) reqTimes.shift(); + const on = onlineSet(); + const bySection = {}; + for (const [, v] of on) bySection[v.section] = (bySection[v.section] || 0) + 1; + return { + onlineTotal: on.size, + bySection, + reqPerMin: reqTimes.length, + reqTotal, + rssMb: Math.round(process.memoryUsage().rss / 1048576), + uptimeSec: Math.round(process.uptime()), + }; +} + +export function onlineUsers() { + return [...onlineSet().entries()].map(([id, v]) => ({ id, name: v.name, section: v.section, secAgo: v.secAgo })); +} + +export const getActivity = (limit = 150) => activity.slice(0, limit); + +export function dailyByUser() { + const today = new Date().toISOString().slice(0, 10); + const m = {}; + for (const a of activity) { + if (a.ts.slice(0, 10) !== today) continue; + (m[a.userId] = m[a.userId] || { name: a.name, count: 0, last: a.ts }).count++; + } + return Object.entries(m).map(([id, v]) => ({ id, ...v })); +} + +// Раздел по пути API (для статистики онлайн-по-разделам) +export function sectionOf(path) { + if (path.includes('/entities') || path.includes('/tracks') || path.includes('/trips')) return 'GPS'; + if (path.includes('/channels')) return 'Смарт'; + if (path.includes('/energy')) return 'Энерго'; + if (path.includes('/devices')) return 'Устройства'; + if (path.includes('/admin')) return 'Админ'; + if (path.includes('/billing')) return 'Кабинет'; + return '—'; +} diff --git a/server/src/server.js b/server/src/server.js new file mode 100644 index 0000000..6d6e721 --- /dev/null +++ b/server/src/server.js @@ -0,0 +1,197 @@ +import http from 'node:http'; +import express from 'express'; +import cors from 'cors'; +import { WebSocketServer } from 'ws'; +import { config } from './config.js'; +import { sign, verifyId, signState, verifyState, can, isObjectAllowed, TYPE_SECTION } from './auth.js'; +import * as tenants from './tenants.js'; +import * as metrics from './metrics.js'; +import * as store from './store.js'; +import { seedDemo } from './demo.js'; +import { startMqtt } from './adapters/mqtt.js'; +import { startTraccar } from './adapters/traccar.js'; +import { startInflux } from './adapters/influx.js'; + +seedDemo(); +const live = { wirenboard: startMqtt(), traccar: startTraccar(), influx: startInflux() }; + +const app = express(); +app.use(cors()); +app.use(express.json()); + +function auth(req, res, next) { + const u = tenants.getUser(verifyId((req.headers.authorization || '').replace(/^Bearer /, ''))); + if (!u) return res.status(401).json({ error: 'unauthorized' }); + if (tenants.isBlocked(u)) return res.status(403).json({ error: 'Доступ заблокирован', blocked: u.blocked }); + req.user = u; + metrics.touch(u, metrics.sectionOf(req.path)); + next(); +} +function adminOnly(req, res, next) { + if (!req.user?.admin) return res.status(403).json({ error: 'forbidden' }); + next(); +} +// Видимость: мультиарендность по clientId (admin/'all' видят всё) + RBAC раздела/объекта. +const visible = (user, e) => { + if (!user.admin && user.clientId !== 'all' && e.clientId !== user.clientId) return false; + const sec = TYPE_SECTION[e.type]; + return can(user, sec, 'view') && isObjectAllowed(user, sec, e.id); +}; +const filterEnt = (user, list) => list.filter((e) => visible(user, e)); + +app.get('/api/health', (_req, res) => res.json({ ok: true, sources: live })); +// Вход по id — только для seed-ролей/demo (переключатель). Яндекс-арендаторы (yx-*) — только через OAuth. +app.post('/api/login', (req, res) => { + const id = String(req.body?.userId || 'demo'); + if (id.startsWith('yx-')) return res.status(403).json({ error: 'Для этого пользователя вход только через Яндекс ID' }); + const u = tenants.getUser(id) || tenants.getUser('demo'); + res.json({ token: sign(u), user: u }); +}); +app.get('/api/me', auth, (req, res) => res.json(req.user)); + +// Устройства арендатора (привязка трекеров по IMEI → clientId владельца) +app.get('/api/devices', auth, (req, res) => res.json(tenants.devicesOf(req.user.id))); +app.post('/api/devices/bind', auth, (req, res) => { + const imei = String(req.body?.imei || '').trim(); + if (!/^\d{6,20}$/.test(imei)) return res.status(400).json({ error: 'Некорректный IMEI (только цифры)' }); + const device = tenants.bindDevice(req.user.id, imei, req.body?.name); + const e = store.getEntity(`nt-${imei}`); + if (e) store.upsertEntity({ id: e.id, clientId: req.user.clientId }); // пере-тег уже принятой сущности + res.json({ ok: true, device }); +}); +app.delete('/api/devices/:imei', auth, (req, res) => { + tenants.unbindDevice(req.user.id, req.params.imei); + res.json({ ok: true }); +}); +app.get('/api/entities', auth, (req, res) => { + let l = filterEnt(req.user, store.getEntities()); + if (req.query.type) l = l.filter((e) => e.type === req.query.type); + res.json(l); +}); +app.get('/api/tracks/:id', auth, (req, res) => res.json(store.getTrack(req.params.id))); +app.get('/api/trips', auth, (req, res) => res.json(store.getTrips(req.query.entityId))); +app.get('/api/events', auth, (req, res) => res.json(store.getEvents())); +app.get('/api/channels', auth, (req, res) => res.json(can(req.user, 'smart', 'view') ? store.getChannels() : [])); +app.get('/api/energy/series', auth, (req, res) => res.json(can(req.user, 'energy', 'view') ? store.getEnergySeries() : [])); + +// --- Админ-консоль (только admin) --- +app.get('/api/admin/stats', auth, adminOnly, (_req, res) => res.json(metrics.stats())); +app.get('/api/admin/online', auth, adminOnly, (_req, res) => res.json(metrics.onlineUsers())); +app.get('/api/admin/activity', auth, adminOnly, (_req, res) => res.json(metrics.getActivity())); +app.get('/api/admin/daily', auth, adminOnly, (_req, res) => res.json(metrics.dailyByUser())); +app.get('/api/admin/users', auth, adminOnly, (_req, res) => + res.json( + tenants.allUsers().map((u) => ({ + id: u.id, + name: u.name, + email: u.email || '', + role: u.role || '', + admin: !!u.admin, + clientId: u.clientId, + blocked: u.blocked || null, + sections: Object.keys(u.access || {}), + })), + ), +); +app.post('/api/admin/users', auth, adminOnly, (req, res) => res.json(tenants.addUser(req.body || {}))); +app.delete('/api/admin/users/:id', auth, adminOnly, (req, res) => res.json({ ok: tenants.deleteUser(req.params.id) })); +app.post('/api/admin/users/:id/block', auth, adminOnly, (req, res) => res.json(tenants.setBlock(req.params.id, req.body || {}) || { error: 'not found' })); +app.post('/api/admin/users/:id/unblock', auth, adminOnly, (req, res) => res.json(tenants.clearBlock(req.params.id) || { error: 'not found' })); + +// --- Личный кабинет / биллинг (заглушка оплаты ЮMoney/Сбербанк) --- +const balances = {}; +app.get('/api/billing', auth, (req, res) => { + const cid = req.user.clientId; + res.json({ clientId: cid, balance: balances[cid] ?? 0, currency: 'RUB', plan: req.user.admin ? 'Безлимит' : 'Базовый', devices: tenants.devicesOf(req.user.id).length }); +}); +app.post('/api/billing/topup', auth, (req, res) => { + const amount = Math.max(0, Number(req.body?.amount) || 0); + const method = String(req.body?.method || 'yoomoney'); + const order = `sm-${req.user.clientId}-${Date.now()}`; + // ЗАГЛУШКА: реальная интеграция ЮMoney/Сбербанк — позже (см. план billing). + const payUrl = + method === 'sberbank' + ? `https://3dsec.sberbank.ru/payment/merchants/servaki/payment_ru.html?mdOrder=${order}` + : `https://yoomoney.ru/quickpay/confirm.xml?receiver=4100100000000&quickpay-form=shop&targets=Servaki+Monitor&sum=${amount}&label=${order}`; + res.json({ ok: true, demo: true, amount, method, order, payUrl, note: 'Демо-заглушка: оплата не списывается' }); +}); + +// Связанные аккаунты кабинета (общий clientId) +app.get('/api/cabinet/links', auth, (req, res) => res.json(tenants.linksOf(req.user.id))); +app.post('/api/cabinet/links', auth, (req, res) => res.json(tenants.addLink(req.user.id, req.body?.email))); +app.delete('/api/cabinet/links/:email', auth, (req, res) => res.json(tenants.removeLink(req.user.id, decodeURIComponent(req.params.email)))); + +// --- Яндекс ID (OAuth) --- +app.get('/api/auth/yandex/start', (_req, res) => { + if (!config.yandex) return res.redirect('/?autherror=noyandex'); + const u = new URL('https://oauth.yandex.ru/authorize'); + u.searchParams.set('response_type', 'code'); + u.searchParams.set('client_id', config.yandex.clientId); + u.searchParams.set('redirect_uri', config.yandex.redirect); + u.searchParams.set('state', signState()); // CSRF-защита + res.redirect(u.toString()); +}); +app.get('/api/auth/yandex/callback', async (req, res) => { + if (!config.yandex) return res.redirect('/?autherror=noyandex'); + // Пользователь отклонил доступ / ошибка Яндекса + if (req.query.error) return res.redirect(`/?autherror=${encodeURIComponent(String(req.query.error))}`); + // Проверка state (CSRF) + if (!verifyState(req.query.state)) return res.redirect('/?autherror=state'); + if (!req.query.code) return res.redirect('/?autherror=nocode'); + try { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: String(req.query.code), + client_id: config.yandex.clientId, + client_secret: config.yandex.secret, + }); + const tok = await (await fetch('https://oauth.yandex.ru/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body })).json(); + if (!tok.access_token) return res.redirect('/?autherror=token'); + const info = await (await fetch('https://login.yandex.ru/info?format=json', { headers: { Authorization: `OAuth ${tok.access_token}` } })).json(); + if (!info.id) return res.redirect('/?autherror=userinfo'); + const u = tenants.upsertYandex({ + email: String(info.default_email || ''), + name: info.real_name || info.display_name || info.login, + sub: String(info.id), + }); + res.redirect(`/?token=${sign(u)}`); + } catch { + res.redirect('/?autherror=oauth'); + } +}); + +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: '/socket' }); +wss.on('connection', (ws, req) => { + const user = tenants.getUser(verifyId(new URL(req.url, 'http://x').searchParams.get('token'))); + if (!user || tenants.isBlocked(user)) return ws.close(); + metrics.wsConnect(user); + const send = (type, data) => { + try { + ws.send(JSON.stringify({ type, data })); + } catch { + /* socket closed */ + } + }; + send('snapshot', { entities: filterEnt(user, store.getEntities()) }); + const onPos = (p) => { + const e = store.getEntity(p.id); + if (e && visible(user, e)) send('position', p); + }; + const onEnt = (e) => visible(user, e) && send('entity', e); + const onEv = (ev) => send('event', ev); + const onCh = (c) => can(user, 'smart', 'view') && send('channel', c); + store.bus.on('position', onPos); + store.bus.on('entity', onEnt); + store.bus.on('event', onEv); + store.bus.on('channel', onCh); + ws.on('close', () => { + metrics.wsDisconnect(user.id); + store.bus.off('position', onPos); + store.bus.off('entity', onEnt); + store.bus.off('event', onEv); + store.bus.off('channel', onCh); + }); +}); + +server.listen(config.port, () => console.log(`[api] :${config.port} · источники`, live)); diff --git a/server/src/store.js b/server/src/store.js new file mode 100644 index 0000000..489079c --- /dev/null +++ b/server/src/store.js @@ -0,0 +1,77 @@ +// Состояние в памяти + шина событий для WebSocket. Треки = история позиций по объекту. +import { EventEmitter } from 'node:events'; + +export const bus = new EventEmitter(); +bus.setMaxListeners(0); + +const MAX_TRACK = 1000; +const state = { + entities: new Map(), + tracks: new Map(), // id -> [{lat,lon,ts}] + events: [], + channels: [], + energySeries: [], + trips: [], +}; + +export function upsertEntity(e) { + const prev = state.entities.get(e.id) || {}; + const merged = { + clientId: 'default', + type: 'transport', + name: e.id, + online: true, + telemetry: {}, + ...prev, + ...e, + telemetry: { ...(prev.telemetry || {}), ...(e.telemetry || {}) }, + updatedAt: e.updatedAt || new Date().toISOString(), + }; + state.entities.set(merged.id, merged); + bus.emit('entity', merged); + return merged; +} + +export function addPosition(id, lat, lon, telemetry = {}, ts) { + const now = ts || new Date().toISOString(); + const e = upsertEntity({ id, lat, lon, telemetry, online: true, updatedAt: now }); + let t = state.tracks.get(id); + if (!t) { + t = []; + state.tracks.set(id, t); + } + t.push({ lat, lon, ts: now }); + if (t.length > MAX_TRACK) t.shift(); + bus.emit('position', { id, lat, lon, ts: now }); + return e; +} + +export const setChannels = (chs) => { + state.channels = chs; +}; +export function updateChannel(id, patch) { + const c = state.channels.find((x) => x.id === id); + if (c) { + Object.assign(c, patch); + bus.emit('channel', c); + } +} +export function addEvent(ev) { + state.events.unshift(ev); + if (state.events.length > 200) state.events.pop(); + bus.emit('event', ev); +} +export const setEnergySeries = (s) => { + state.energySeries = s; +}; +export const setTrips = (t) => { + state.trips = t; +}; + +export const getEntities = () => [...state.entities.values()]; +export const getEntity = (id) => state.entities.get(id); +export const getTrack = (id) => state.tracks.get(id) || []; +export const getEvents = () => state.events; +export const getChannels = () => state.channels; +export const getEnergySeries = () => state.energySeries; +export const getTrips = (id) => (id ? state.trips.filter((t) => t.entityId === id) : state.trips); diff --git a/server/src/tenants.js b/server/src/tenants.js new file mode 100644 index 0000000..4d0f69c --- /dev/null +++ b/server/src/tenants.js @@ -0,0 +1,180 @@ +// Арендаторы (мультиарендность). Seed-роли + динамические Яндекс-пользователи. +// Привязка устройств по IMEI → clientId владельца. Персист в JSON-файл. +import fs from 'node:fs'; +import path from 'node:path'; +import { USERS as SEED } from './auth.js'; +import { config } from './config.js'; + +const FILE = path.join(config.dataDir, 'tenants.json'); +let users = []; +let bindings = {}; // imei -> { userId, clientId, name } + +// Единый набор прав администратора (источник — seed admin). Включает все разделы, +// в т.ч. новые (security/ПЦН) — чтобы добавление раздела доезжало без ручных правок tenants.json. +const ADMIN_ACCESS = (SEED.find((s) => s.admin) || {}).access || {}; + +function persist() { + try { + fs.mkdirSync(config.dataDir, { recursive: true }); + fs.writeFileSync(FILE, JSON.stringify({ users, bindings }, null, 2)); + } catch (e) { + console.error('[tenants] save', e.message); + } +} + +function init() { + try { + const j = JSON.parse(fs.readFileSync(FILE, 'utf8')); + users = j.users || []; + bindings = j.bindings || {}; + } catch { + users = []; + bindings = {}; + } + for (const s of SEED) { + const u = users.find((x) => x.id === s.id); + if (!u) users.push({ ...s, clientId: s.admin ? 'all' : 'tempelhoff' }); + else u.access = s.access; // прокидываем изменения RBAC из seed (новые разделы, напр. security) + } + // Существующие Яндекс-админы — обновляем права до актуального админ-набора (идемпотентно). + for (const u of users) if (u.admin && u.id.startsWith('yx-')) u.access = { ...ADMIN_ACCESS }; + persist(); + console.log(`[tenants] пользователей: ${users.length}, привязок устройств: ${Object.keys(bindings).length}`); +} +init(); + +export const getUser = (id) => users.find((u) => u.id === id) || null; +export const allUsers = () => users; + +export function upsertYandex({ email, name, sub }) { + const id = 'yx-' + sub; + const isAdmin = (config.yandex?.admins || []).includes(String(email || '').toLowerCase()); + let u = users.find((x) => x.id === id); + if (!u) { + // если email привязан к чужому кабинету — присоединяемся к нему (общий clientId) + const linkCid = isAdmin ? null : linkedClientId(email); + u = { + id, + email, + name: name || email || 'Пользователь', + role: isAdmin ? 'Администратор' : linkCid ? 'Связанный аккаунт' : 'Кабинет арендатора', + admin: isAdmin, + clientId: isAdmin ? 'all' : linkCid || 't-' + String(sub).slice(-6), + // Новый арендатор — GPS-кабинет своих трекеров (+ «Мои устройства»). + // Энерго/Смарт/Сеть — инфра-разделы, выдаёт админ при необходимости. + access: isAdmin ? { ...ADMIN_ACCESS } : { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } }, + }; + users.push(u); + persist(); + } else if (isAdmin && !u.admin) { + u.admin = true; + u.clientId = 'all'; + u.access = { ...ADMIN_ACCESS }; + persist(); + } + return u; +} + +// --- Привязка нескольких аккаунтов к одному кабинету (общий clientId) --- +// Владелец кабинета добавляет email; когда тот входит через Яндекс — получает тот же clientId. +export function linkedClientId(email) { + const e = String(email || '').toLowerCase(); + if (!e) return null; + const owner = users.find((u) => Array.isArray(u.linked) && u.linked.includes(e)); + return owner ? owner.clientId : null; +} +export function linksOf(userId) { + const u = getUser(userId); + return (u && u.linked) || []; +} +export function addLink(userId, email) { + const u = getUser(userId); + if (!u) return []; + const e = String(email || '').trim().toLowerCase(); + u.linked = Array.isArray(u.linked) ? u.linked : []; + if (e && !u.linked.includes(e)) { + u.linked.push(e); + // если такой пользователь уже входил и не админ — сразу присоединить к кабинету + const existing = users.find((x) => String(x.email || '').toLowerCase() === e && x.id !== userId && !x.admin); + if (existing) existing.clientId = u.clientId; + persist(); + } + return u.linked; +} +export function removeLink(userId, email) { + const u = getUser(userId); + if (!u || !Array.isArray(u.linked)) return []; + u.linked = u.linked.filter((x) => x !== String(email || '').toLowerCase()); + persist(); + return u.linked; +} + +export function bindDevice(userId, imei, name) { + const u = getUser(userId); + if (!u) return null; + bindings[imei] = { userId, clientId: u.clientId, name: name || 'Трекер ' + imei }; + persist(); + return bindings[imei]; +} +export function unbindDevice(userId, imei) { + if (bindings[imei] && bindings[imei].userId === userId) { + delete bindings[imei]; + persist(); + } +} +export const devicesOf = (userId) => + Object.entries(bindings) + .filter(([, b]) => b.userId === userId) + .map(([imei, b]) => ({ imei, name: b.name })); +export const imeiOwner = (imei) => (bindings[imei] ? bindings[imei].clientId : null); + +// --- Админ: блокировки и CRUD --- +// blocked = { from, until } (ISO|null). Блок активен, если now в окне. +export function setBlock(id, { from = null, until = null } = {}) { + const u = getUser(id); + if (!u || u.admin) return null; + u.blocked = { from: from || null, until: until || null }; + persist(); + return u; +} +export function clearBlock(id) { + const u = getUser(id); + if (!u) return null; + delete u.blocked; + persist(); + return u; +} +export function isBlocked(user, nowIso) { + const b = user && user.blocked; + if (!b) return false; + const now = nowIso || new Date().toISOString(); + if (b.from && now < b.from) return false; // блок ещё не начался + if (b.until && now > b.until) return false; // блок истёк / разморожен + return true; +} +export function addUser({ email, name, clientId } = {}) { + const base = (email || name || 'user').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 12) || 'user'; + let id = 'u-' + base; + let n = 1; + while (getUser(id)) id = `u-${base}${n++}`; + const u = { + id, + email: email || '', + name: name || email || id, + role: 'Арендатор', + clientId: clientId || 't-' + id.slice(-6), + access: { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } }, + }; + users.push(u); + persist(); + return u; +} +export function deleteUser(id) { + const i = users.findIndex((u) => u.id === id); + if (i >= 0 && !users[i].admin) { + users.splice(i, 1); + persist(); + return true; + } + return false; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..8cb4581 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { Activity } from 'lucide-react'; +import { AppShell } from '@/components/layout/AppShell'; +import { LoginScreen } from '@/components/LoginScreen'; +import { visibleModules } from '@/config/modules'; +import { useAuth } from '@/core/store'; +import { DEMO_USER_ID } from '@/core/auth'; + +export default function App() { + const { user, authed, ready, bootstrap, loginDemo } = useAuth(); + const [authError, setAuthError] = useState(null); + + // Бутстрап SaaS-сессии: ?token=… → /me (см. store.bootstrap). Яндекс-арендаторы — только через OAuth. + useEffect(() => { + bootstrap(); + }, [bootstrap]); + + // Ошибка OAuth-входа: /?autherror=… → показать на экране входа + useEffect(() => { + const e = new URLSearchParams(window.location.search).get('autherror'); + if (e) { + setAuthError(e); + window.history.replaceState({}, '', window.location.pathname); + } + }, []); + + // Демо/дев-вход по ?yauth= — ТОЛЬКО в dev-сборке И ТОЛЬКО для демо-пользователя. + // В проде параметр игнорируется (см. issue #1). Даже в dev ?yauth=admin (и любой не-demo id) + // не даёт входа: остальные роли — только через реальный Яндекс ID (/api/auth/yandex/start). + useEffect(() => { + if (!import.meta.env.DEV) return; + const y = new URLSearchParams(window.location.search).get('yauth'); + if (y) { + if (y === DEMO_USER_ID) loginDemo(); + window.history.replaceState({}, '', window.location.pathname); + } + }, [loginDemo]); + + if (!ready) { + return ( +
+ +
+ ); + } + if (!authed) return ; + + const mods = visibleModules(user); + const home = mods[0]?.path ?? '/access'; + return ( + + }> + {mods.map((m) => ( + } /> + ))} + } /> + + + ); +} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..ecca511 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,75 @@ +// Клиент шлюза. Токен сессии в localStorage (переживает обновление страницы). +const BASE = (import.meta.env.VITE_API_BASE as string) || '/api'; + +let token = ''; +try { + token = localStorage.getItem('sm-token') || ''; +} catch { + /* ignore */ +} + +export function setToken(t: string) { + token = t; + try { + if (t) localStorage.setItem('sm-token', t); + else localStorage.removeItem('sm-token'); + } catch { + /* ignore */ + } +} +export const getToken = () => token; + +export async function apiGet(path: string): Promise { + const r = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${token}` } }); + if (!r.ok) throw new Error(String(r.status)); + return r.json() as Promise; +} +export async function apiPost(path: string, body: unknown): Promise { + const r = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(String(r.status)); + return r.json() as Promise; +} +export async function apiDelete(path: string): Promise { + const r = await fetch(`${BASE}${path}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); + const txt = await r.text(); + return (txt ? JSON.parse(txt) : undefined) as T; +} + +// Вход seed-роли/demo по id → выдаёт токен (и сохраняет его). +export async function login(userId: string) { + const r = await fetch(`${BASE}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }), + }); + if (!r.ok) throw new Error('login failed'); + const d = await r.json(); + setToken(d.token); + return d as { token: string; user: any }; +} +// Пути БЕЗ префикса /api — его добавляет BASE. +export const me = () => apiGet('/me'); + +export type SocketMsg = + | { type: 'snapshot'; data: { entities: any[] } } + | { type: 'position'; data: { id: string; lat: number; lon: number; ts: string } } + | { type: 'entity'; data: any } + | { type: 'event'; data: any } + | { type: 'channel'; data: any }; + +export function openSocket(onMsg: (m: SocketMsg) => void): WebSocket { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${proto}://${location.host}/socket?token=${encodeURIComponent(token)}`); + ws.onmessage = (e) => { + try { + onMsg(JSON.parse(e.data)); + } catch { + /* ignore */ + } + }; + return ws; +} diff --git a/src/components/ExportPdfButton.tsx b/src/components/ExportPdfButton.tsx new file mode 100644 index 0000000..c1bc7bb --- /dev/null +++ b/src/components/ExportPdfButton.tsx @@ -0,0 +1,17 @@ +import { FileText } from 'lucide-react'; +import { useAuth } from '@/core/store'; +import { exportPdf, type ReportSpec } from '@/lib/report'; + +export function ExportPdfButton({ sectionTitle, build }: { sectionTitle: string; build: () => ReportSpec }) { + const user = useAuth((s) => s.user); + return ( + + ); +} diff --git a/src/components/Lightning.tsx b/src/components/Lightning.tsx new file mode 100644 index 0000000..e1840e4 --- /dev/null +++ b/src/components/Lightning.tsx @@ -0,0 +1,94 @@ +import { useRef, useEffect } from 'react'; + +// WebGL «молния» из проекта odyssey — фон правой половины логина «Железный Егорин». +// Тёплый hue по умолчанию (amber). Контекст создаётся один раз, обработка потери/восстановления. +interface LightningProps { + hue?: number; + xOffset?: number; + speed?: number; + intensity?: number; + size?: number; + className?: string; +} + +export function Lightning({ hue = 35, xOffset = 0, speed = 1, intensity = 1, size = 1, className }: LightningProps) { + const canvasRef = useRef(null); + const propsRef = useRef({ hue, xOffset, speed, intensity, size }); + propsRef.current = { hue, xOffset, speed, intensity, size }; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const resizeCanvas = () => { canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight; }; + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + const gl = canvas.getContext('webgl'); + if (!gl) return; + + const vs = `attribute vec2 aPosition; void main(){ gl_Position = vec4(aPosition,0.0,1.0); }`; + const fs = ` + precision mediump float; + uniform vec2 iResolution; uniform float iTime; uniform float uHue; + uniform float uXOffset; uniform float uSpeed; uniform float uIntensity; uniform float uSize; + #define OCTAVE_COUNT 10 + vec3 hsv2rgb(vec3 c){vec3 rgb=clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0),6.0)-3.0)-1.0,0.0,1.0);return c.z*mix(vec3(1.0),rgb,c.y);} + float hash11(float p){p=fract(p*.1031);p*=p+33.33;p*=p+p;return fract(p);} + float hash12(vec2 p){vec3 p3=fract(vec3(p.xyx)*.1031);p3+=dot(p3,p3.yzx+33.33);return fract((p3.x+p3.y)*p3.z);} + mat2 rotate2d(float theta){float c=cos(theta);float s=sin(theta);return mat2(c,-s,s,c);} + float noise(vec2 p){vec2 ip=floor(p);vec2 fp=fract(p);float a=hash12(ip);float b=hash12(ip+vec2(1.0,0.0));float c=hash12(ip+vec2(0.0,1.0));float d=hash12(ip+vec2(1.0,1.0));vec2 t=smoothstep(0.0,1.0,fp);return mix(mix(a,b,t.x),mix(c,d,t.x),t.y);} + float fbm(vec2 p){float value=0.0;float amplitude=0.5;for(int i=0;i { + const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); return s; + }; + const program = gl.createProgram()!; + gl.attachShader(program, compile(vs, gl.VERTEX_SHADER)); + gl.attachShader(program, compile(fs, gl.FRAGMENT_SHADER)); + gl.linkProgram(program); gl.useProgram(program); + + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, -1,1, 1,-1, 1,1]), gl.STATIC_DRAW); + const aPosition = gl.getAttribLocation(program, 'aPosition'); + gl.enableVertexAttribArray(aPosition); + gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0); + + const uRes = gl.getUniformLocation(program, 'iResolution'); + const uTime = gl.getUniformLocation(program, 'iTime'); + const uHue = gl.getUniformLocation(program, 'uHue'); + const uX = gl.getUniformLocation(program, 'uXOffset'); + const uSpeed = gl.getUniformLocation(program, 'uSpeed'); + const uInt = gl.getUniformLocation(program, 'uIntensity'); + const uSizeL = gl.getUniformLocation(program, 'uSize'); + + const t0 = performance.now(); + let raf = 0; + const render = () => { + gl.viewport(0, 0, canvas.width, canvas.height); + gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform1f(uTime, (performance.now() - t0) / 1000); + const p = propsRef.current; + gl.uniform1f(uHue, p.hue); gl.uniform1f(uX, p.xOffset); gl.uniform1f(uSpeed, p.speed); + gl.uniform1f(uInt, p.intensity); gl.uniform1f(uSizeL, p.size); + gl.drawArrays(gl.TRIANGLES, 0, 6); + raf = requestAnimationFrame(render); + }; + const onLost = (e: Event) => { e.preventDefault(); cancelAnimationFrame(raf); }; + const onRestored = () => { raf = requestAnimationFrame(render); }; + canvas.addEventListener('webglcontextlost', onLost as EventListener); + canvas.addEventListener('webglcontextrestored', onRestored); + raf = requestAnimationFrame(render); + + return () => { + window.removeEventListener('resize', resizeCanvas); + canvas.removeEventListener('webglcontextlost', onLost as EventListener); + canvas.removeEventListener('webglcontextrestored', onRestored); + cancelAnimationFrame(raf); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ; +} diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx new file mode 100644 index 0000000..63850ed --- /dev/null +++ b/src/components/LoginScreen.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react'; +import { Lightning } from './Lightning'; +import { Activity, Eye, ArrowRight, Zap, Navigation, Network, ShieldCheck, ChevronDown, Send, Phone, Mail } from 'lucide-react'; +import { useAuth } from '@/core/store'; + +interface Feature { + icon: typeof Zap; + title: string; + desc: string; + options: string[]; +} + +const FEATURES: Feature[] = [ + { icon: Zap, title: 'Энергомониторинг', desc: 'Sunways · Wiren Board · щитовые шкафы', options: ['Контроль питания щитов по FLEX (AIN3)', 'Sunways: PV, SOC, ток/напряжение АКБ', 'Тревоги: пропадание сети, перегрев BMS', 'Графики по объектам + отчёты PDF'] }, + { icon: Navigation, title: 'GPS-мониторинг', desc: 'Navtelecom FLEX · треки · геозоны', options: ['Треки перемещений и слежение в реальном времени', 'Журнал поездок и построение отчётов', 'Геозоны и события', 'Карты 2ГИС / Яндекс / OSM'] }, + { icon: Network, title: 'Сетевая инфраструктура', desc: 'mesh2 · узлы и связанность', options: ['Узлы Keenetic / Mikrotik / камеры', 'Визуализация связанности и задержек', 'Кадры с камер и приборов', 'Карта топологии mesh2'] }, + { icon: ShieldCheck, title: 'Доступ по ролям', desc: 'мультиарендность · RBAC', options: ['Права на разделы и отдельные объекты', 'Уровни: просмотр / правка / отчёты', 'Вход через Яндекс ID', 'Мультиарендность по clientId'] }, +]; + +function FeatureItem({ f }: { f: Feature }) { + const [open, setOpen] = useState(false); + const [contact, setContact] = useState(false); + const mail = `mailto:info@servaki.online?subject=${encodeURIComponent('Запрос КП — ' + f.title)}&body=${encodeURIComponent('Здравствуйте! Интересует модуль «' + f.title + '» в системе «Железный Егорин». Прошу прислать коммерческое предложение.')}`; + return ( +
+ +
+
+
+
    + {f.options.map((o) => ( +
  • + + {o} +
  • + ))} +
+
+ + {contact && ( + + )} +
+
+
+
+
+ ); +} + +const AUTH_ERRORS: Record = { + state: 'Сессия входа истекла или запрос не подтверждён. Попробуйте войти ещё раз.', + access_denied: 'Вы отменили вход через Яндекс ID.', + token: 'Не удалось получить токен от Яндекса. Повторите попытку.', + userinfo: 'Не удалось получить данные профиля Яндекса.', + nocode: 'Яндекс не вернул код авторизации.', + noyandex: 'Вход через Яндекс ID временно недоступен.', + oauth: 'Ошибка авторизации. Повторите попытку или войдите демо-доступом.', +}; + +export function LoginScreen({ error }: { error?: string | null }) { + const loginDemo = useAuth((s) => s.loginDemo); + const errMsg = error ? AUTH_ERRORS[error] || 'Ошибка авторизации. Повторите попытку.' : null; + + return ( +
+ {/* Брендовая панель */} +
+ + + + + {[ + [150, 50, 1], [230, 180, 1], [180, 330, 1], [280, 540, 1], + [60, 80, 0], [320, 90, 0], [90, 200, 0], [350, 230, 0], [50, 360, 0], [300, 400, 0], [130, 500, 0], [360, 470, 0], + ].map(([x, y, accent], i) => ( + + {accent ? : null} + + ))} + +
+
+ +
+
+ +
+
+
Железный Егорин
+
Единая смартпанель мониторинга
+
+
+ +
+

+ Энергетика, транспорт и сеть — в одном пульте +

+
+ {FEATURES.map((f) => ( + + ))} +
+
+ +
+ © servaki.online · приём данных Navtelecom FLEX/NTCB · RU-инфраструктура +
+
+ + {/* Панель входа — правая половина: odyssey WebGL-молния (тёплая), форма поверх стеклом */} +
+
+ +
+
+
+
+
+
+ +
+
Железный Егорин
+
+
+ +

Вход в систему

+

Авторизуйтесь, чтобы открыть пульт мониторинга.

+ + {errMsg && ( +
+ {errMsg} +
+ )} + + + +
+ + или + +
+ + + +

Доступ к данным — по ролям и арендатору (clientId).

+
+
+
+ ); +} diff --git a/src/components/MapFrame.tsx b/src/components/MapFrame.tsx new file mode 100644 index 0000000..1266f9a --- /dev/null +++ b/src/components/MapFrame.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { Maximize2, Minimize2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Обёртка карты с кнопкой «на весь экран». render-prop даёт текущий режим, +// чтобы карта внутри растягивалась на h-full в фуллскрине. +export function MapFrame({ children }: { children: (full: boolean) => ReactNode }) { + const [full, setFull] = useState(false); + useEffect(() => { + const t = setTimeout(() => window.dispatchEvent(new Event('resize')), 90); + return () => clearTimeout(t); + }, [full]); + return ( +
+ +
{children(full)}
+
+ ); +} diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx new file mode 100644 index 0000000..2df1e1e --- /dev/null +++ b/src/components/PageHeader.tsx @@ -0,0 +1,28 @@ +import type { ComponentType, ReactNode } from 'react'; + +export function PageHeader({ + icon: Icon, + title, + desc, + actions, +}: { + icon: ComponentType<{ className?: string }>; + title: string; + desc?: string; + actions?: ReactNode; +}) { + return ( +
+
+
+ +
+
+

{title}

+ {desc &&

{desc}

} +
+
+ {actions} +
+ ); +} diff --git a/src/components/RadioMiniWindow.tsx b/src/components/RadioMiniWindow.tsx new file mode 100644 index 0000000..aa89b95 --- /dev/null +++ b/src/components/RadioMiniWindow.tsx @@ -0,0 +1,70 @@ +import { createPortal } from 'react-dom'; +import { Radio, X, Volume2, VolumeX, Play } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useRadio, speak, beep } from '@/core/radio'; + +const tm = (iso: string) => new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +const DOT: Record = { critical: 'bg-crit', warning: 'bg-warn', info: 'bg-info', ok: 'bg-ok' }; + +// Мини-окно «Рация»: включение/отключение оповещения + эфир. Открывается кнопкой в шапке. +export function RadioMiniWindow({ open, onClose }: { open: boolean; onClose: () => void }) { + const { on, ether, toggle } = useRadio(); + if (!open) return null; + + const test = () => { + beep(on ? 880 : 440, 0.14, 0.25); + if (on) speak('Проверка связи. Рация в эфире.'); + }; + + return createPortal( +
+
+
+ + + +
+
Рация
+
{on ? 'оповещение включено' : 'оповещение выключено'}
+
+
+ +
+ + {/* Вкл/выкл оповещение */} +
+ + +
+ + {/* Эфир */} +
+
Эфир · последние события
+
+ {ether.length === 0 &&
Пока тихо. События появятся здесь.
} + {ether.map((e, i) => ( +
+ +
+
+ {e.callsign} + {tm(e.ts)} +
+
{e.text}
+
+
+ ))} +
+
+
, + document.body, + ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..878e57c --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -0,0 +1,240 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { Radio, Activity, ChevronDown, Check, Sun, Moon, LogOut, PanelLeftClose, PanelLeftOpen, Wallet } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { visibleModules } from '@/config/modules'; +import { StatusDot } from '@/components/ui/primitives'; +import { events } from '@/data/mock'; +import { useAuth } from '@/core/store'; +import { useTheme } from '@/core/theme'; +import { useRadio, useRadioListener } from '@/core/radio'; +import { RadioMiniWindow } from '@/components/RadioMiniWindow'; +import { USERS } from '@/core/auth'; + +function ThemeToggle() { + const { theme, toggle } = useTheme(); + return ( + + ); +} + +function initials(name: string): string { + return name + .split(/\s+/) + .map((w) => w[0]) + .join('') + .slice(0, 2) + .toUpperCase(); +} + +function UserSwitcher() { + const { user, setUser, logout } = useAuth(); + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + return ( +
+ + {open && + createPortal( + <> +
setOpen(false)} /> +
+
Войти как (демо)
+ {USERS.map((u) => ( + + ))} +
+ + +
+
+ , + document.body, + )} +
+ ); +} + +export function AppShell() { + const user = useAuth((s) => s.user); + const mods = visibleModules(user); + const crit = events.filter((e) => e.severity === 'critical').length; + const { on: radioOn, ether } = useRadio(); + useRadioListener(); + const [confirmHome, setConfirmHome] = useState(false); + const [showRadio, setShowRadio] = useState(false); + const [navCollapsed, setNavCollapsed] = useState(() => { + try { + return localStorage.getItem('sm-nav') === '1'; + } catch { + return false; + } + }); + const toggleNav = () => + setNavCollapsed((v) => { + try { + localStorage.setItem('sm-nav', v ? '0' : '1'); + } catch { + /* ignore */ + } + return !v; + }); + const lbl = navCollapsed ? 'hidden' : 'hidden md:inline'; + const lblBlock = navCollapsed ? 'hidden' : 'hidden md:block'; + + return ( +
+ + +
+
+
+ + + {new Date().toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit' })} + +
+
+ + + +
+
+ + setShowRadio(false)} /> + +
+ +
+
+ + {confirmHome && ( +
+
setConfirmHome(false)} /> +
+

Вернуться на главную?

+

Панель будет обновлена — перезагрузятся разделы и сущности.

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/components/ui/Collapsible.tsx b/src/components/ui/Collapsible.tsx new file mode 100644 index 0000000..dcfb497 --- /dev/null +++ b/src/components/ui/Collapsible.tsx @@ -0,0 +1,76 @@ +import { useState, type ReactNode } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** «Ролл»-панель: заголовок-кнопка раскрывает блок с подробными свойствами. */ +export function Collapsible({ + title, + subtitle, + left, + right, + defaultOpen = false, + onToggle, + children, + className, +}: { + title: ReactNode; + subtitle?: ReactNode; + left?: ReactNode; + right?: ReactNode; + defaultOpen?: boolean; + onToggle?: (open: boolean) => void; + children: ReactNode; + className?: string; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ +
+
+
{children}
+
+
+
+ ); +} + +/** Таблица «свойство → значение» для содержимого ролл-панели. */ +export function PropList({ rows }: { rows: Array<[string, ReactNode]> }) { + return ( +
+ {rows.map(([k, v], i) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ); +} diff --git a/src/components/ui/KpiCard.tsx b/src/components/ui/KpiCard.tsx new file mode 100644 index 0000000..d218a9f --- /dev/null +++ b/src/components/ui/KpiCard.tsx @@ -0,0 +1,60 @@ +import type { ComponentType, ReactNode } from 'react'; +import { cn, fmt } from '@/lib/utils'; +import { Card } from './primitives'; + +type Tone = 'accent' | 'ok' | 'warn' | 'crit' | 'info' | 'muted'; + +const TONE: Record = { + accent: 'text-accent', + ok: 'text-ok', + warn: 'text-warn', + crit: 'text-crit', + info: 'text-info', + muted: 'text-fg', +}; + +// Цветной контур плитки по статусу (красный/жёлтый/зелёный…) +const RING: Record = { + accent: 'ring-2 ring-accent/50', + ok: 'ring-2 ring-ok/50', + warn: 'ring-2 ring-warn/50', + crit: 'ring-2 ring-crit/70', + info: 'ring-2 ring-info/50', + muted: '', +}; + +export function KpiCard({ + label, + value, + unit, + digits = 1, + icon: Icon, + tone = 'muted', + ring, + sub, +}: { + label: string; + value: number | string; + unit?: string; + digits?: number; + icon?: ComponentType<{ className?: string }>; + tone?: Tone; + ring?: Tone; // окрас контура по статусу (напр. ПЦН: тревога=crit, нет связи=warn) + sub?: ReactNode; +}) { + return ( + +
+ {label} + {Icon && } +
+
+ + {typeof value === 'number' ? fmt(value, digits) : value} + + {unit && {unit}} +
+ {sub &&
{sub}
} +
+ ); +} diff --git a/src/components/ui/primitives.tsx b/src/components/ui/primitives.tsx new file mode 100644 index 0000000..b55e57f --- /dev/null +++ b/src/components/ui/primitives.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import type { Severity } from '@/core/types'; + +export function Card({ className, children }: { className?: string; children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function CardHeader({ + title, + desc, + right, +}: { + title: ReactNode; + desc?: ReactNode; + right?: ReactNode; +}) { + return ( +
+
+

{title}

+ {desc &&

{desc}

} +
+ {right} +
+ ); +} + +const SEV_STYLES: Record = { + critical: 'bg-crit/15 text-crit', + warning: 'bg-warn/15 text-warn', + info: 'bg-info/15 text-info', + ok: 'bg-ok/15 text-ok', +}; + +export function Badge({ + severity = 'info', + children, + className, +}: { + severity?: Severity; + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +const DOT: Record = { + critical: 'bg-crit', + warning: 'bg-warn', + info: 'bg-info', + ok: 'bg-ok', + offline: 'bg-fg-muted', +}; + +export function StatusDot({ state, pulse }: { state: Severity | 'offline'; pulse?: boolean }) { + return ( + + {pulse && state !== 'offline' && ( + + )} + + + ); +} diff --git a/src/config/modules.ts b/src/config/modules.ts new file mode 100644 index 0000000..71ce5a4 --- /dev/null +++ b/src/config/modules.ts @@ -0,0 +1,54 @@ +import type { ComponentType } from 'react'; +import { Zap, Cpu, Navigation, Network, ShieldCheck, Siren, Smartphone, FolderGit2, Gauge, Wallet, BellRing, MapPinned, History, MapPin, Building2, Archive } from 'lucide-react'; +import { EnergyModule } from '@/modules/energy/EnergyModule'; +import { SmartModule } from '@/modules/smart/SmartModule'; +import { LocationsModule } from '@/modules/smart/LocationsModule'; +import { SecurityModule } from '@/modules/security/SecurityModule'; +import { ObjectsModule } from '@/modules/security/ObjectsModule'; +import { ArchiveModule } from '@/modules/security/ArchiveModule'; +import { GpsModule } from '@/modules/gps/GpsModule'; +import { GeoModule } from '@/modules/geo/GeoModule'; +import { EventsModule } from '@/modules/gps/EventsModule'; +import { NetworkModule } from '@/modules/network/NetworkModule'; +import { DevicesModule } from '@/modules/devices/DevicesModule'; +import { CabinetModule } from '@/modules/cabinet/CabinetModule'; +import { AccessModule } from '@/modules/access/AccessModule'; +import { AdminModule } from '@/modules/admin/AdminModule'; +import { NotifyModule } from '@/modules/notify/NotifyModule'; +import { ReposModule } from '@/modules/repos/ReposModule'; +import { can, type SectionId, type User } from '@/core/auth'; + +export interface ModuleDef { + id: string; + path: string; + title: string; + short: string; + icon: ComponentType<{ className?: string }>; + component: ComponentType; + section?: SectionId; // раздел для RBAC (нет — служебный, напр. админский) + adminOnly?: boolean; + hidden?: boolean; // маршрут есть, но в левом сайдбаре НЕ показывать (подпункт/служебный) +} + +export const MODULES: ModuleDef[] = [ + { id: 'energy', path: '/energy', title: 'Энергомониторинг', short: 'Энерго', icon: Zap, component: EnergyModule, section: 'energy' }, + { id: 'smart', path: '/smart', title: 'Смартпанель', short: 'Смарт', icon: Cpu, component: SmartModule, section: 'smart' }, + { id: 'smart-locations', path: '/smart-locations', title: 'Локации', short: 'Локации', icon: MapPin, component: LocationsModule, section: 'smart', hidden: true }, + { id: 'security', path: '/security', title: 'Охранный пульт (ПЦН)', short: 'Охрана', icon: Siren, component: SecurityModule, section: 'security' }, + { id: 'security-objects', path: '/security-objects', title: 'Охраняемые объекты', short: 'Объекты', icon: Building2, component: ObjectsModule, section: 'security', hidden: true }, + { id: 'security-archive', path: '/security-archive', title: 'Архив сработок', short: 'Архив', icon: Archive, component: ArchiveModule, section: 'security', hidden: true }, + { id: 'gps', path: '/gps', title: 'GPS-мониторинг', short: 'GPS', icon: Navigation, component: GpsModule, section: 'gps' }, + { id: 'geo', path: '/geofences', title: 'Геозоны', short: 'Геозоны', icon: MapPinned, component: GeoModule, section: 'gps', hidden: true }, + { id: 'events', path: '/events', title: 'История событий', short: 'События', icon: History, component: EventsModule, section: 'gps', hidden: true }, + { id: 'network', path: '/network', title: 'Сетевая инфраструктура', short: 'Сеть', icon: Network, component: NetworkModule, section: 'netinfra' }, + { id: 'devices', path: '/devices', title: 'Мои устройства', short: 'Устройства', icon: Smartphone, component: DevicesModule }, + { id: 'cabinet', path: '/cabinet', title: 'Личный кабинет', short: 'Кабинет', icon: Wallet, component: CabinetModule, hidden: true }, + { id: 'access', path: '/access', title: 'Доступ и роли', short: 'Доступ', icon: ShieldCheck, component: AccessModule, adminOnly: true }, + { id: 'notify', path: '/notify', title: 'Уведомления', short: 'Уведомл.', icon: BellRing, component: NotifyModule, adminOnly: true }, + { id: 'admin', path: '/admin', title: 'Админ-консоль', short: 'Админ', icon: Gauge, component: AdminModule, adminOnly: true }, + { id: 'repos', path: '/repos', title: 'Карта репозиториев', short: 'Репо', icon: FolderGit2, component: ReposModule, adminOnly: true }, +]; + +// Видимые пользователю модули: служебные — по admin, разделы — по праву view. +export const visibleModules = (user: User): ModuleDef[] => + MODULES.filter((m) => (m.adminOnly ? !!user.admin : m.section ? can(user, m.section, 'view') : true)); diff --git a/src/core/auth.ts b/src/core/auth.ts new file mode 100644 index 0000000..a5c5945 --- /dev/null +++ b/src/core/auth.ts @@ -0,0 +1,130 @@ +// RBAC: у пользователя — права на РАЗДЕЛЫ и на ОБЪЕКТЫ внутри раздела + уровни. +// Уровни: view (просмотр) · edit (редактирование) · reports (отчёты/журнал). +export type Capability = 'view' | 'edit' | 'reports'; +export type SectionId = 'energy' | 'smart' | 'gps' | 'netinfra' | 'security'; + +export interface SectionAccess { + caps: Capability[]; + objects: string[] | 'all'; +} + +export interface User { + id: string; + name: string; + role?: string; + admin?: boolean; + clientId?: string; // арендатор (приходит с сервера для мультиарендности) + access: Partial>; +} + +export const SECTIONS: SectionId[] = ['energy', 'smart', 'gps', 'netinfra', 'security']; + +export const SECTION_LABEL: Record = { + energy: 'Энергомониторинг', + smart: 'Смартпанель', + gps: 'GPS-мониторинг', + netinfra: 'Сетевая инфраструктура', + security: 'Охранный пульт (ПЦН)', +}; + +export const CAP_LABEL: Record = { + view: 'Просмотр', + edit: 'Редактирование', + reports: 'Отчёты', +}; + +export const ALL_CAPS: Capability[] = ['view', 'edit', 'reports']; + +export const USERS: User[] = [ + { + id: 'admin', + name: 'Администратор', + role: 'Полный доступ', + admin: true, + access: { + energy: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + smart: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + gps: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + netinfra: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + security: { caps: ['view', 'edit', 'reports'], objects: 'all' }, + }, + }, + { + id: 'pcn-operator', + name: 'Дежурный ПЦН', + role: 'Оператор пульта · приём тревог, ГБР', + access: { security: { caps: ['view', 'edit', 'reports'], objects: 'all' } }, + }, + { + id: 'energetik-ro', + name: 'Сидоров А.', + role: 'Энергетик · часть, только просмотр', + access: { energy: { caps: ['view'], objects: ['cab-07'] } }, + }, + { + id: 'energetik-rw', + name: 'Петров В.', + role: 'Энергетик · редактирование', + access: { energy: { caps: ['view', 'edit', 'reports'], objects: 'all' } }, + }, + { + id: 'logist', + name: 'Кузнецова М.', + role: 'Логист транспорта · отчёты', + access: { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } }, + }, + { + id: 'netadmin', + name: 'Орлов Д.', + role: 'Сетевой инженер · mesh2', + access: { netinfra: { caps: ['view', 'edit', 'reports'], objects: 'all' } }, + }, + { + id: 'demo', + name: 'Демо-доступ', + role: 'Только просмотр (без редактирования)', + access: { + energy: { caps: ['view'], objects: 'all' }, + smart: { caps: ['view'], objects: 'all' }, + gps: { caps: ['view'], objects: 'all' }, + netinfra: { caps: ['view'], objects: 'all' }, + security: { caps: ['view'], objects: 'all' }, + }, + }, +]; + +export const DEMO_USER_ID = 'demo'; + +export function can(user: User, section: SectionId, cap: Capability): boolean { + return !!user.access[section]?.caps.includes(cap); +} + +export function objectScope(user: User, section: SectionId): string[] | 'all' | null { + return user.access[section]?.objects ?? null; +} + +export function isObjectAllowed(user: User, section: SectionId, id: string): boolean { + const o = objectScope(user, section); + return o === 'all' || (Array.isArray(o) && o.includes(id)); +} + +// Грант/отзыв уровня (чекбоксами в матрице). view — базовый: edit/reports требуют view. +export function toggleUserCap(user: User, section: SectionId, cap: Capability): User { + const cur = user.access[section]; + const has = !!cur?.caps.includes(cap); + const access = { ...user.access }; + if (!cur) { + // включаем раздел: любой кап добавляет и view + access[section] = { caps: cap === 'view' ? ['view'] : ['view', cap], objects: 'all' }; + return { ...user, access }; + } + let caps = has ? cur.caps.filter((c) => c !== cap) : [...cur.caps, cap]; + if (cap === 'view' && has) caps = []; // снятие view убирает все + if (caps.length && !caps.includes('view')) caps = ['view', ...caps]; // edit/reports требуют view + if (caps.length === 0) { + delete access[section]; + } else { + access[section] = { ...cur, caps }; + } + return { ...user, access }; +} diff --git a/src/core/notifyEngine.ts b/src/core/notifyEngine.ts new file mode 100644 index 0000000..5cbe608 --- /dev/null +++ b/src/core/notifyEngine.ts @@ -0,0 +1,78 @@ +import type { NotifyRule, NotifySchedule } from '@/data/notify'; +import type { Severity } from '@/core/types'; + +// Движок правил уведомлений: сопоставляет событие с правилами, объясняет ПОЧЕМУ, +// и собирает объединённый набор каналов. Чистые функции — тестируемо и предсказуемо. + +export interface NEvent { + section: string; + severity: Severity; + objectId?: string; + objectName?: string; + text?: string; +} + +export interface RuleMatch { + rule: NotifyRule; + reasons: string[]; +} +export interface RuleMiss { + rule: NotifyRule; + why: string; +} + +// Активно ли расписание правила в момент now +export function scheduleActive(schedule: NotifySchedule, now: Date): boolean { + const h = now.getHours(); + const day = now.getDay(); // 0=вс … 6=сб + const workday = day >= 1 && day <= 5; + if (schedule === 'always') return true; + if (schedule === 'work') return workday && h >= 9 && h < 18; + if (schedule === 'night') return !workday || h < 9 || h >= 18; + return true; +} + +export function evaluate(ev: NEvent, rules: NotifyRule[], now: Date = new Date()): { + matched: RuleMatch[]; + missed: RuleMiss[]; + channels: string[]; +} { + const matched: RuleMatch[] = []; + const missed: RuleMiss[] = []; + + for (const r of rules) { + if (!r.enabled) { + missed.push({ rule: r, why: 'правило выключено' }); + continue; + } + const secOk = r.sections.length === 0 || r.sections.includes(ev.section); + const sevOk = r.severities.length === 0 || r.severities.includes(ev.severity); + const schedOk = scheduleActive(r.schedule, now); + + if (!secOk) { + missed.push({ rule: r, why: `раздел «${ev.section}» не в списке правила` }); + continue; + } + if (!sevOk) { + missed.push({ rule: r, why: `важность «${ev.severity}» не в списке правила` }); + continue; + } + if (!schedOk) { + missed.push({ rule: r, why: 'вне расписания правила' }); + continue; + } + const reasons: string[] = [ + r.sections.length ? `раздел ∈ {${r.sections.join(', ')}}` : 'любой раздел', + r.severities.length ? `важность ∈ {${r.severities.join(', ')}}` : 'любая важность', + 'расписание активно', + ]; + matched.push({ rule: r, reasons }); + } + + // Объединённый набор каналов (по приоритету правил) + const order = matched.slice().sort((a, b) => b.rule.priority - a.rule.priority); + const channels: string[] = []; + for (const m of order) for (const c of m.rule.channels) if (!channels.includes(c)) channels.push(c); + + return { matched, missed, channels }; +} diff --git a/src/core/radio.ts b/src/core/radio.ts new file mode 100644 index 0000000..97c6c25 --- /dev/null +++ b/src/core/radio.ts @@ -0,0 +1,120 @@ +import { useEffect } from 'react'; +import { create } from 'zustand'; +import { openSocket } from '@/api/client'; +import type { MonitorEvent } from './types'; + +// «Рация»: звук на события (Web Audio) + голосовой доклад (Web Speech). +// Голос/звук разрешаются браузером только после жеста — праймим на включении тумблера. + +let audioCtx: AudioContext | null = null; +function ctx(): AudioContext | null { + try { + if (!audioCtx) audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + if (audioCtx.state === 'suspended') audioCtx.resume(); + return audioCtx; + } catch { + return null; + } +} + +export function beep(freq = 880, dur = 0.16, vol = 0.25) { + const c = ctx(); + if (!c) return; + const o = c.createOscillator(); + const g = c.createGain(); + o.connect(g); + g.connect(c.destination); + o.type = 'sine'; + o.frequency.value = freq; + const t = c.currentTime; + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(vol, t + 0.01); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + o.start(t); + o.stop(t + dur + 0.02); +} + +// Двойной сигнал для critical, одиночный для warning +function chime(sev: MonitorEvent['severity']) { + if (sev === 'critical') { + beep(988, 0.18, 0.3); + setTimeout(() => beep(740, 0.22, 0.3), 200); + } else if (sev === 'warning') { + beep(660, 0.14, 0.22); + } else { + beep(523, 0.1, 0.15); + } +} + +export function speak(text: string) { + try { + if (!('speechSynthesis' in window)) return; + const u = new SpeechSynthesisUtterance(text); + u.lang = 'ru-RU'; + u.rate = 1; + u.pitch = 1; + const ru = window.speechSynthesis.getVoices().find((v) => v.lang.toLowerCase().startsWith('ru')); + if (ru) u.voice = ru; + window.speechSynthesis.speak(u); + } catch { + /* ignore */ + } +} + +// Радиосеанс по событию: сигнал + голосовой доклад +export function radioReport(ev: MonitorEvent) { + chime(ev.severity); + const prefix = ev.severity === 'critical' ? 'Внимание. ' : ''; + setTimeout(() => speak(`${prefix}${ev.callsign}. ${ev.text}`), ev.severity === 'critical' ? 500 : 200); +} + +interface RadioState { + on: boolean; + ether: MonitorEvent[]; // лента «эфир» + toggle: () => void; + push: (ev: MonitorEvent) => void; +} + +const initOn = (() => { + try { + return localStorage.getItem('sm-radio') === '1'; + } catch { + return false; + } +})(); + +export const useRadio = create((set, get) => ({ + on: initOn, + ether: [], + toggle: () => { + const on = !get().on; + try { + localStorage.setItem('sm-radio', on ? '1' : '0'); + } catch { + /* ignore */ + } + if (on) { + ctx(); // прайм аудио на жесте + beep(880, 0.08, 0.18); // подтверждающий бип + speak('Рация включена'); + } + set({ on }); + }, + push: (ev) => set((s) => ({ ether: [ev, ...s.ether].slice(0, 40) })), +})); + +// Глобальный слушатель событий по WS: лента «эфир» + радиосеанс при включённой Рации. +export function useRadioListener() { + const push = useRadio((s) => s.push); + useEffect(() => { + const ws = openSocket((msg) => { + if (msg.type === 'event' && msg.data) { + const ev = msg.data as MonitorEvent; + push(ev); + if (useRadio.getState().on && (ev.severity === 'critical' || ev.severity === 'warning')) radioReport(ev); + } + }); + return () => ws.close(); + }, [push]); +} + diff --git a/src/core/store.ts b/src/core/store.ts new file mode 100644 index 0000000..5e4d80f --- /dev/null +++ b/src/core/store.ts @@ -0,0 +1,52 @@ +import { create } from 'zustand'; +import { USERS, toggleUserCap, type Capability, type SectionId, type User } from './auth'; +import { login as apiLogin, me as apiMe, setToken } from '@/api/client'; + +const DEMO = USERS.find((u) => u.id === 'demo') || USERS[0]; + +interface AuthState { + user: User; // активный (с сервера); до входа — заглушка demo, рендер гейтится authed + authed: boolean; + ready: boolean; // бутстрап завершён + users: User[]; // seed-роли — для переключателя и матрицы доступа (демо) + bootstrap: () => Promise; + loginDemo: () => Promise; + setUser: (u: User) => Promise; + logout: () => void; + toggleCap: (userId: string, section: SectionId, cap: Capability) => void; +} + +export const useAuth = create((set) => ({ + user: DEMO, + authed: false, + ready: false, + users: USERS, + bootstrap: async () => { + const t = new URLSearchParams(window.location.search).get('token'); + if (t) { + setToken(t); + window.history.replaceState({}, '', window.location.pathname); + } + try { + const u = (await apiMe()) as User; + set({ user: u, authed: true, ready: true }); + } catch { + setToken(''); // просроченный/невалидный токен — очищаем, чтобы не зациклиться на 401 + set({ authed: false, ready: true }); + } + }, + loginDemo: async () => { + const { user } = await apiLogin('demo'); + set({ user, authed: true }); + }, + setUser: async (u) => { + const { user } = await apiLogin(u.id); + set({ user, authed: true }); + }, + logout: () => { + setToken(''); + set({ authed: false }); + }, + toggleCap: (userId, section, cap) => + set((s) => ({ users: s.users.map((u) => (u.id === userId ? toggleUserCap(u, section, cap) : u)) })), +})); diff --git a/src/core/theme.ts b/src/core/theme.ts new file mode 100644 index 0000000..af70e1e --- /dev/null +++ b/src/core/theme.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; + +export type Theme = 'dark' | 'light'; + +function apply(t: Theme) { + document.documentElement.classList.toggle('dark', t === 'dark'); +} + +function initial(): Theme { + try { + const s = localStorage.getItem('sm-theme') as Theme | null; + if (s === 'dark' || s === 'light') return s; + } catch { + /* ignore */ + } + return 'dark'; +} + +const start = initial(); +apply(start); + +interface ThemeState { + theme: Theme; + toggle: () => void; +} + +export const useTheme = create((set, get) => ({ + theme: start, + toggle: () => { + const t: Theme = get().theme === 'dark' ? 'light' : 'dark'; + apply(t); + try { + localStorage.setItem('sm-theme', t); + } catch { + /* ignore */ + } + set({ theme: t }); + }, +})); diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..d632385 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,37 @@ +// Типонезависимое ядро мониторинга (по ТЗ ORBIT): одна модель для transport/energy/realty. +// Источники (Traccar / Wiren Board MQTT / InfluxDB) нормализуются адаптерами в MonitoredEntity. + +export type Severity = 'critical' | 'warning' | 'info' | 'ok'; +export type EntityType = 'transport' | 'energy' | 'realty' | 'network'; + +export interface MonitoredEntity { + id: string; + clientId: string; // обязательная изоляция мультиарендности + name: string; + type: EntityType; + online: boolean; + telemetry: Record; // нормализованные метрики (В, А, Вт, °C, %) + meta?: Record; // строковые свойства (IP, модель, роль…) + lat?: number; + lon?: number; + updatedAt: string; // ISO +} + +// Единый контракт события для EventBus → лента / звук / голос («Рация») +export interface MonitorEvent { + id: string; + entityId: string; + callsign: string; + severity: Severity; + type: string; // power_loss | door_open | batt_low | temp_high | offline ... + text: string; + ts: string; // ISO + ackRequired?: boolean; +} + +export const SEVERITY_RANK: Record = { + critical: 3, + warning: 2, + info: 1, + ok: 0, +}; diff --git a/src/data/alarmHandling.ts b/src/data/alarmHandling.ts new file mode 100644 index 0000000..dbb8bfd --- /dev/null +++ b/src/data/alarmHandling.ts @@ -0,0 +1,83 @@ +import { create } from 'zustand'; + +// Отработка сработки ПЦН: стадии с таймстампами + журнал комментариев (кто/когда). +// Диспетчерский цикл: приём → взято в работу → ГБР выехал → ГБР прибыл → осмотр → закрыто. +// Хранение — localStorage (в проде — на шлюзе, привязано к сработке; вложения фото/видео в S3). + +export interface StageDef { + key: string; + label: string; + short: string; +} +export const STAGES: StageDef[] = [ + { key: 'taken', label: 'Взято в работу', short: 'Взял' }, + { key: 'enroute', label: 'ГБР выехал', short: 'Выезд' }, + { key: 'onsite', label: 'ГБР прибыл', short: 'Прибытие' }, + { key: 'inspect', label: 'Осмотр объекта', short: 'Осмотр' }, + { key: 'closed', label: 'Отработано (закрыто)', short: 'Закрыть' }, +]; + +// Типовые комментарии (без выдумываний — фиксированный справочник) +export const PRESET_COMMENTS = [ + 'Принял в работу', + 'Оповещён владелец', + 'Дозвон по списку', + 'Передано ГБР', + 'ГБР выехал на объект', + 'ГБР прибыл на объект', + 'Осмотр — нарушений нет', + 'Осмотр — выявлено проникновение', + 'Ложное срабатывание', + 'Отработано, объект под охраной', +]; + +export interface HandlingComment { + ts: string; + text: string; + author: string; +} +export interface Handling { + stages: Record; // stageKey -> ISO ts + comments: HandlingComment[]; +} + +const KEY = 'sm-alarm-handling'; +function load(): Record { + try { + return JSON.parse(localStorage.getItem(KEY) || '{}'); + } catch { + return {}; + } +} + +interface HState { + map: Record; + stamp: (alarmId: string, stage: string, iso: string) => void; + addComment: (alarmId: string, text: string, author: string, iso: string) => void; +} + +function persist(map: Record) { + try { + localStorage.setItem(KEY, JSON.stringify(map)); + } catch { + /* quota */ + } +} + +export const useAlarmHandling = create((set) => ({ + map: load(), + stamp: (alarmId, stage, iso) => + set((s) => { + const cur = s.map[alarmId] || { stages: {}, comments: [] }; + const map = { ...s.map, [alarmId]: { ...cur, stages: { ...cur.stages, [stage]: iso } } }; + persist(map); + return { map }; + }), + addComment: (alarmId, text, author, iso) => + set((s) => { + const cur = s.map[alarmId] || { stages: {}, comments: [] }; + const map = { ...s.map, [alarmId]: { ...cur, comments: [...cur.comments, { ts: iso, text, author }] } }; + persist(map); + return { map }; + }), +})); diff --git a/src/data/geofences.ts b/src/data/geofences.ts new file mode 100644 index 0000000..e3e89ce --- /dev/null +++ b/src/data/geofences.ts @@ -0,0 +1,84 @@ +import { create } from 'zustand'; + +// Геозоны (круговые). Хранение localStorage; в проде — на шлюзе per-clientId. +// Вход/выход из зоны — триггер для конфигуратора уведомлений [[useNotify]]. + +export interface Geofence { + id: string; + name: string; + lat: number; + lon: number; + radius: number; // метры + color: string; + enabled: boolean; +} + +export const ZONE_PALETTE = ['#22C55E', '#0EA5E9', '#F59E0B', '#EF4444', '#A78BFA', '#EC4899']; + +const DEFAULTS: Geofence[] = [ + { id: 'z-base', name: 'База «Тэмпельхоф»', lat: 44.07, lon: 43.05, radius: 800, color: '#22C55E', enabled: true }, + { id: 'z-pyatigorsk', name: 'Пятигорск, рынок', lat: 44.043, lon: 43.071, radius: 500, color: '#0EA5E9', enabled: true }, +]; + +const KEY = 'sm-geofences'; +function load(): Geofence[] { + try { + const j = localStorage.getItem(KEY); + if (j) return JSON.parse(j); + } catch { + /* ignore */ + } + return DEFAULTS; +} +function persist(zones: Geofence[]) { + try { + localStorage.setItem(KEY, JSON.stringify(zones)); + } catch { + /* quota */ + } +} + +export function emptyZone(): Geofence { + return { id: 'z-' + Math.abs(Date.now() % 1e9).toString(36), name: 'Новая зона', lat: 44.05, lon: 43.06, radius: 500, color: ZONE_PALETTE[0], enabled: true }; +} + +// Гаверсинус (м) — проверка вхождения объекта в зону +export function distM(aLat: number, aLon: number, bLat: number, bLon: number): number { + const R = 6371000; + const dLat = ((bLat - aLat) * Math.PI) / 180; + const dLon = ((bLon - aLon) * Math.PI) / 180; + const la1 = (aLat * Math.PI) / 180; + const la2 = (bLat * Math.PI) / 180; + const x = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(x))); +} + +interface GeoState { + zones: Geofence[]; + upsert: (z: Geofence) => void; + remove: (id: string) => void; + toggle: (id: string) => void; +} + +export const useGeofences = create((set) => ({ + zones: load(), + upsert: (z) => + set((s) => { + const i = s.zones.findIndex((x) => x.id === z.id); + const zones = i >= 0 ? s.zones.map((x) => (x.id === z.id ? z : x)) : [...s.zones, z]; + persist(zones); + return { zones }; + }), + remove: (id) => + set((s) => { + const zones = s.zones.filter((x) => x.id !== id); + persist(zones); + return { zones }; + }), + toggle: (id) => + set((s) => { + const zones = s.zones.map((x) => (x.id === id ? { ...x, enabled: !x.enabled } : x)); + persist(zones); + return { zones }; + }), +})); diff --git a/src/data/guardObjects.ts b/src/data/guardObjects.ts new file mode 100644 index 0000000..547413f --- /dev/null +++ b/src/data/guardObjects.ts @@ -0,0 +1,63 @@ +import { create } from 'zustand'; +import { guardObjects as SEED, type GuardObject } from './security'; + +// CRUD охраняемых объектов ПЦН. Персист в localStorage (сид — из security.ts). +// В проде — из БД пульта per-clientId. + +const KEY = 'sm-guard-objects'; +function load(): GuardObject[] { + try { + const j = localStorage.getItem(KEY); + if (j) return JSON.parse(j); + } catch { + /* ignore */ + } + return SEED; +} +function persist(v: GuardObject[]) { + try { + localStorage.setItem(KEY, JSON.stringify(v)); + } catch { + /* quota */ + } +} + +export function emptyObject(): GuardObject { + return { + id: 'obj-' + Math.abs(Date.now() % 1e9).toString(36), + clientId: 'tempelhoff', + account: '', + name: 'Новый объект', + address: '', + lat: 44.05, + lon: 43.06, + state: 'armed', + proto: 'SIA-DC09', + channel: 'GPRS', + contacts: [], + zones: [], + }; +} + +interface GState { + objects: GuardObject[]; + upsert: (o: GuardObject) => void; + remove: (id: string) => void; +} + +export const useGuardObjects = create((set) => ({ + objects: load(), + upsert: (o) => + set((s) => { + const i = s.objects.findIndex((x) => x.id === o.id); + const objects = i >= 0 ? s.objects.map((x) => (x.id === o.id ? o : x)) : [...s.objects, o]; + persist(objects); + return { objects }; + }), + remove: (id) => + set((s) => { + const objects = s.objects.filter((x) => x.id !== id); + persist(objects); + return { objects }; + }), +})); diff --git a/src/data/locations.ts b/src/data/locations.ts new file mode 100644 index 0000000..d86c739 --- /dev/null +++ b/src/data/locations.ts @@ -0,0 +1,89 @@ +import { create } from 'zustand'; + +// Локации Смартпанели: плитки, чекбокс «на главный экран» → карточка на главном. +// Хранение localStorage; в проде — из инвентаря Wiren Board per-clientId. + +export interface SmartChannel { + name: string; + kind: 'relay' | 'dimmer' | 'sensor' | 'climate'; + on: boolean; + value?: string; +} +export interface Location { + id: string; + clientId: string; // арендатор-владелец (изоляция мультиарендности) + name: string; + address: string; + onMain: boolean; // показывать карточку на главном экране Смартпанели + channels: SmartChannel[]; +} + +// Виден ли элемент пользователю: админ (clientId 'all') видит всё, арендатор — только свои. +export function scopedTo(user: { admin?: boolean; clientId?: string }, items: T[]): T[] { + if (user.admin || user.clientId === 'all') return items; + return items.filter((i) => i.clientId === user.clientId); +} + +const DEFAULTS: Location[] = [ + { id: 'loc-office', clientId: 'tempelhoff', name: 'Офис', address: 'Железноводск, ул. Пушкина, 1Е', onMain: true, channels: [ + { name: 'Свет офис', kind: 'dimmer', on: true, value: '65%' }, + { name: 'Кондиционер', kind: 'climate', on: true, value: '23°C' }, + { name: 'Датчик двери', kind: 'sensor', on: false, value: 'закрыта' }, + ] }, + { id: 'loc-warehouse', clientId: 'tempelhoff', name: 'Склад «Весовая»', address: 'Ульяновка, ул. Весовая, 1', onMain: true, channels: [ + { name: 'Освещение цех', kind: 'relay', on: true }, + { name: 'Ворота', kind: 'relay', on: false }, + { name: 'Насос полива', kind: 'relay', on: true }, + ] }, + { id: 'loc-shop', clientId: 'tempelhoff', name: 'Магазин ЭлектроКМВ', address: 'Минводы, ул. Советская, 24', onMain: false, channels: [ + { name: 'Витрина', kind: 'dimmer', on: true, value: '80%' }, + { name: 'Кондиционер зал', kind: 'climate', on: true, value: '21°C' }, + ] }, + { id: 'loc-farm', clientId: 'tempelhoff', name: 'Ферма', address: 'Ставропольский край', onMain: false, channels: [ + { name: 'Подсветка фасад', kind: 'dimmer', on: false, value: '0%' }, + { name: 'Полив', kind: 'relay', on: true }, + { name: 'Темп. коровник', kind: 'sensor', on: true, value: '18°C' }, + ] }, +]; + +const KEY = 'sm-locations'; +function load(): Location[] { + try { + const j = localStorage.getItem(KEY); + if (j) return JSON.parse(j); + } catch { + /* ignore */ + } + return DEFAULTS; +} +function persist(v: Location[]) { + try { + localStorage.setItem(KEY, JSON.stringify(v)); + } catch { + /* quota */ + } +} + +interface LocState { + locations: Location[]; + toggleMain: (id: string) => void; + toggleChannel: (locId: string, ch: string) => void; +} + +export const useLocations = create((set) => ({ + locations: load(), + toggleMain: (id) => + set((s) => { + const locations = s.locations.map((l) => (l.id === id ? { ...l, onMain: !l.onMain } : l)); + persist(locations); + return { locations }; + }), + toggleChannel: (locId, ch) => + set((s) => { + const locations = s.locations.map((l) => + l.id === locId ? { ...l, channels: l.channels.map((c) => (c.name === ch ? { ...c, on: !c.on } : c)) } : l, + ); + persist(locations); + return { locations }; + }), +})); diff --git a/src/data/mesh2.ts b/src/data/mesh2.ts new file mode 100644 index 0000000..5138df0 --- /dev/null +++ b/src/data/mesh2.ts @@ -0,0 +1,81 @@ +// Узлы/устройства сети mesh2 (агро-объект «Тэмпельхоф», 14 локаций — демо-выборка). +// В проде — из cascade-панели (mesh-selftest state.json) + инвентаря Keenetic/Mikrotik. +import type { MonitoredEntity } from '@/core/types'; + +const now = new Date().toISOString(); +const ago = (m: number) => new Date(Date.now() - m * 60000).toISOString(); + +export const meshNodes: MonitoredEntity[] = [ + { + id: 'mesh2-hub', + clientId: 'tempelhoff', + name: 'finik-хаб', + type: 'network', + online: true, + telemetry: { latency: 1.2, uplink: 940, clients: 38, cpu: 17 }, + meta: { ip: '10.200.0.1', model: 'x86 / AmneziaWG', role: 'Центральный хаб mesh2', uplink: 'GPON 1 Гбит' }, + lat: 44.072, + lon: 43.05, + updatedAt: now, + }, + { + id: 'mesh2-kn1', + clientId: 'tempelhoff', + name: 'Keenetic · Склад', + type: 'network', + online: true, + telemetry: { latency: 3.4, uplink: 310, clients: 12, cpu: 22 }, + meta: { ip: '10.200.1.2', model: 'Keenetic Hopper SE', role: 'Спица · склад', uplink: 'AWG → хаб' }, + lat: 44.064, + lon: 43.058, + updatedAt: now, + }, + { + id: 'mesh2-kn2', + clientId: 'tempelhoff', + name: 'Keenetic · Ферма', + type: 'network', + online: true, + telemetry: { latency: 5.1, uplink: 95, clients: 7, cpu: 19 }, + meta: { ip: '10.200.2.2', model: 'Keenetic Hero 4G', role: 'Спица · ферма (LTE-бэкап)', uplink: 'AWG → хаб' }, + lat: 44.081, + lon: 43.041, + updatedAt: now, + }, + { + id: 'mesh2-mt1', + clientId: 'tempelhoff', + name: 'Mikrotik · Поля', + type: 'network', + online: true, + telemetry: { latency: 8.7, uplink: 60, clients: 4, cpu: 31 }, + meta: { ip: '10.200.3.2', model: 'Mikrotik hAP ac3', role: 'Спица · полевой узел', uplink: 'Радиомост 5 ГГц' }, + lat: 44.09, + lon: 43.07, + updatedAt: now, + }, + { + id: 'mesh2-cam1', + clientId: 'tempelhoff', + name: 'Камера · Въезд', + type: 'network', + online: true, + telemetry: { latency: 6.0, uplink: 8, clients: 1, cpu: 0 }, + meta: { ip: '10.200.1.51', model: 'Hikvision DS-2CD', role: 'IP-камера (RTSP)', uplink: 'PoE → Keenetic Склад' }, + lat: 44.066, + lon: 43.06, + updatedAt: now, + }, + { + id: 'mesh2-solar', + clientId: 'tempelhoff', + name: 'Контроллер · Солн.', + type: 'network', + online: false, + telemetry: { latency: 0, uplink: 0, clients: 0, cpu: 0 }, + meta: { ip: '10.200.2.40', model: 'Wiren Board / Modbus-TCP', role: 'Солнечный контроллер', uplink: 'нет связи 14 мин' }, + lat: 44.083, + lon: 43.045, + updatedAt: ago(14), + }, +]; diff --git a/src/data/mock.ts b/src/data/mock.ts new file mode 100644 index 0000000..e95cd5c --- /dev/null +++ b/src/data/mock.ts @@ -0,0 +1,132 @@ +// Демо-данные (моки). Позже заменяются адаптерами: energy→InfluxDB/Grafana, +// realty→Wiren Board MQTT (deviceBridge), transport→Traccar REST/WS. +import type { MonitorEvent, MonitoredEntity } from '@/core/types'; + +// --- Энерго: суточный профиль Sunways (PV мощность / SOC АКБ) --- +export interface EnergyPoint { + t: string; // HH:MM + pv: number; // Вт PV суммарно + soc: number; // % заряд АКБ + load: number; // Вт нагрузка +} + +export const energyDay: EnergyPoint[] = Array.from({ length: 24 }, (_, h) => { + const solar = Math.max(0, Math.sin(((h - 6) / 12) * Math.PI)); // 0 ночью, пик в 12 + const pv = Math.round(solar * 4200 + (solar > 0 ? Math.random() * 200 : 0)); + const load = Math.round(600 + Math.sin((h / 24) * Math.PI * 2) * 250 + Math.random() * 80); + const soc = Math.round(40 + solar * 55); + return { t: `${String(h).padStart(2, '0')}:00`, pv, soc, load }; +}); + +// Генерация PV по нескольким объектам (несколько линий на одном графике) +export const energyObjects = [ + { key: 'inv1', name: 'Инвертор Склад', color: '#22C55E' }, + { key: 'inv2', name: 'Инвертор Ферма', color: '#0EA5E9' }, + { key: 'inv3', name: 'Инвертор Цех', color: '#F59E0B' }, +]; + +export const energyByObject = Array.from({ length: 24 }, (_, h) => { + const s = Math.max(0, Math.sin(((h - 6) / 12) * Math.PI)); + const row: Record = { t: `${String(h).padStart(2, '0')}:00` }; + energyObjects.forEach((o, i) => { + row[o.key] = Math.round(s * (3000 - i * 750) + (s > 0 ? Math.random() * 150 : 0)); // PV, Вт + row[`${o.key}_soc`] = Math.min(100, Math.round(35 + i * 6 + s * (55 - i * 7))); // SOC, % + }); + return row; +}); + +export const energyKpi = { + pvPower: 3.84, // кВт сейчас + soc: 87, // % + battU: 52.6, // В + battI: -8.4, // А (разряд<0) + bmsTemp: 31.5, // °C + pvToday: 22.7, // кВт·ч за сутки +}; + +// --- Realty / Смарт: каналы Wiren Board (реле WB-MR + диммер WB-MDM3) --- +export interface WbChannel { + id: string; + device: string; + name: string; + kind: 'relay' | 'dimmer' | 'input'; + on: boolean; + level?: number; // 0..100 для диммера +} + +export const wbChannels: WbChannel[] = [ + { id: 'mr6c_214/K1', device: 'WB-MR6C #214', name: 'Освещение цех', kind: 'relay', on: true }, + { id: 'mr6c_214/K2', device: 'WB-MR6C #214', name: 'Ворота', kind: 'relay', on: false }, + { id: 'mr6c_214/K3', device: 'WB-MR6C #214', name: 'Насос полива', kind: 'relay', on: true }, + { id: 'mdm3_30/CH1', device: 'WB-MDM3 #30', name: 'Свет офис', kind: 'dimmer', on: true, level: 65 }, + { id: 'mdm3_30/CH2', device: 'WB-MDM3 #30', name: 'Подсветка фасад', kind: 'dimmer', on: false, level: 0 }, + { id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Датчик двери', kind: 'input', on: false }, +]; + +// --- Transport / GPS: объекты Navtelecom (FLEX/NTCB) --- +export const entities: MonitoredEntity[] = [ + { + id: 'veh-123', + clientId: 'tempelhoff', + name: 'ГАЗель-123', + type: 'transport', + online: true, + telemetry: { speed: 64, sat: 11, fuel: 58, voltage: 13.8 }, + lat: 44.05, + lon: 43.06, + updatedAt: new Date().toISOString(), + }, + { + id: 'veh-204', + clientId: 'tempelhoff', + name: 'МТЗ-82 трактор', + type: 'transport', + online: true, + telemetry: { speed: 0, sat: 9, fuel: 31, voltage: 12.9 }, + lat: 44.02, + lon: 43.11, + updatedAt: new Date().toISOString(), + }, + { + id: 'cab-07', + clientId: 'tempelhoff', + name: 'Щит КТП-7', + type: 'energy', + online: false, + telemetry: { pwr_ext: 0, pwr_int: 3.9, temp: 24 }, + lat: 44.08, + lon: 43.0, + updatedAt: new Date(Date.now() - 9 * 60000).toISOString(), + }, +]; + +export const events: MonitorEvent[] = [ + { + id: 'e1', + entityId: 'cab-07', + callsign: 'Щит КТП-7', + severity: 'critical', + type: 'power_loss', + text: 'Пропадание сетевого питания, переход на ИБП.', + ts: new Date(Date.now() - 9 * 60000).toISOString(), + ackRequired: true, + }, + { + id: 'e2', + entityId: 'veh-204', + callsign: 'МТЗ-82 трактор', + severity: 'warning', + type: 'fuel_low', + text: 'Низкий уровень топлива (31%).', + ts: new Date(Date.now() - 42 * 60000).toISOString(), + }, + { + id: 'e3', + entityId: 'veh-123', + callsign: 'ГАЗель-123', + severity: 'info', + type: 'geofence_in', + text: 'Вход в геозону «База».', + ts: new Date(Date.now() - 78 * 60000).toISOString(), + }, +]; diff --git a/src/data/notify.ts b/src/data/notify.ts new file mode 100644 index 0000000..362a8ab --- /dev/null +++ b/src/data/notify.ts @@ -0,0 +1,148 @@ +import { create } from 'zustand'; +import type { ComponentType } from 'react'; +import { Phone, Send, MessageCircle, MessageSquare, Mail, Smartphone, BellRing, Truck, Volume2 } from 'lucide-react'; +import type { Severity } from '@/core/types'; + +// Конфигуратор уведомлений: правило «событие (триггер) → действия (каналы)». +// Гибкая маршрутизация по разделу/важности/приоритету, эскалация, расписание. +// Хранение — localStorage (в проде — на шлюзе per-clientId, EventBus уже есть). + +export interface NotifyChannel { + key: string; + label: string; + icon: ComponentType<{ className?: string }>; + tone: string; // tailwind text color +} + +// Все каналы, вплоть до МАКС +export const CHANNELS: NotifyChannel[] = [ + { key: 'call', label: 'Звонок', icon: Phone, tone: 'text-crit' }, + { key: 'telegram', label: 'Telegram', icon: Send, tone: 'text-info' }, + { key: 'whatsapp', label: 'WhatsApp', icon: MessageCircle, tone: 'text-ok' }, + { key: 'max', label: 'МАКС', icon: MessageSquare, tone: 'text-accent' }, + { key: 'email', label: 'E-mail', icon: Mail, tone: 'text-fg' }, + { key: 'sms', label: 'SMS', icon: Smartphone, tone: 'text-warn' }, + { key: 'push', label: 'Push', icon: BellRing, tone: 'text-info' }, + { key: 'gbr', label: 'Вызов ГБР', icon: Truck, tone: 'text-warn' }, + { key: 'sound', label: 'Звук / Рация', icon: Volume2, tone: 'text-accent' }, +]; +export const CHANNEL_LABEL: Record = Object.fromEntries(CHANNELS.map((c) => [c.key, c.label])); + +export const SEVERITIES: { key: Severity; label: string }[] = [ + { key: 'critical', label: 'Критические' }, + { key: 'warning', label: 'Предупреждения' }, + { key: 'info', label: 'Информационные' }, +]; + +export type NotifySchedule = 'always' | 'work' | 'night'; +export const SCHEDULE_LABEL: Record = { + always: 'Круглосуточно', + work: 'Рабочее время (09–18)', + night: 'Ночь/выходные', +}; + +export interface NotifyRule { + id: string; + name: string; + enabled: boolean; + sections: string[]; // [] = любой раздел + severities: Severity[]; // [] = любая важность + channels: string[]; // ключи CHANNELS + recipients: string; // телефоны / @ / email (свободный текст) + escalateAfterMin: number; // 0 = без эскалации + schedule: NotifySchedule; + priority: number; // 1 (низкий) … 5 (высший) +} + +const DEFAULTS: NotifyRule[] = [ + { + id: 'r-pcn-crit', + name: 'Критические тревоги ПЦН', + enabled: true, + sections: ['security'], + severities: ['critical'], + channels: ['call', 'telegram', 'whatsapp', 'max', 'gbr', 'sound'], + recipients: '+79620151414, ГБР-1, @dezhurny_pcn', + escalateAfterMin: 3, + schedule: 'always', + priority: 5, + }, + { + id: 'r-power', + name: 'Аварии питания (энерго)', + enabled: true, + sections: ['energy'], + severities: ['critical', 'warning'], + channels: ['telegram', 'max', 'email', 'sound'], + recipients: 'energetik@servaki.online, +79620251414', + escalateAfterMin: 10, + schedule: 'always', + priority: 4, + }, + { + id: 'r-offline', + name: 'Пропадание связи (сеть/GPS)', + enabled: true, + sections: ['netinfra', 'gps'], + severities: ['warning'], + channels: ['telegram', 'push'], + recipients: '@netadmin', + escalateAfterMin: 0, + schedule: 'work', + priority: 2, + }, +]; + +const KEY = 'sm-notify-rules'; +function load(): NotifyRule[] { + try { + const j = localStorage.getItem(KEY); + if (j) return JSON.parse(j); + } catch { + /* ignore */ + } + return DEFAULTS; +} +function save(rules: NotifyRule[]) { + try { + localStorage.setItem(KEY, JSON.stringify(rules)); + } catch { + /* quota */ + } +} + +export function emptyRule(): NotifyRule { + return { id: 'r-' + Math.abs(Date.now() % 1e9).toString(36), name: 'Новое правило', enabled: true, sections: [], severities: ['critical'], channels: ['telegram'], recipients: '', escalateAfterMin: 0, schedule: 'always', priority: 3 }; +} + +interface NotifyState { + rules: NotifyRule[]; + upsert: (r: NotifyRule) => void; + remove: (id: string) => void; + toggle: (id: string) => void; +} + +export const useNotify = create((set) => ({ + rules: load(), + upsert: (r) => + set((s) => { + const i = s.rules.findIndex((x) => x.id === r.id); + const rules = i >= 0 ? s.rules.map((x) => (x.id === r.id ? r : x)) : [...s.rules, r]; + save(rules); + return { rules }; + }), + remove: (id) => + set((s) => { + const rules = s.rules.filter((x) => x.id !== id); + save(rules); + return { rules }; + }), + toggle: (id) => + set((s) => { + const rules = s.rules.map((x) => (x.id === id ? { ...x, enabled: !x.enabled } : x)); + save(rules); + return { rules }; + }), +})); + +// emptyRule uses Date.now — вызывается только по клику пользователя (не при рендере/резюме). diff --git a/src/data/repos.ts b/src/data/repos.ts new file mode 100644 index 0000000..4caa4a4 --- /dev/null +++ b/src/data/repos.ts @@ -0,0 +1,78 @@ +// Карта репозиториев Gitea git1.servaki.online (owner german). +// Источник истины — git1; этот файл синхронизируется вручную при инвентаризации. +// Сверено 2026-06-30: всего 32 репо (31 приватный + Heritage public). + +export type RepoCategory = 'routing' | 'infra' | 'bots' | 'sites' | 'knowledge'; + +export interface GiteaRepo { + /** имя = owner german/ */ + name: string; + category: RepoCategory; + /** default-ветка (main, кроме исключений) */ + branch: 'main' | 'master'; + private: boolean; + /** пустой репо (заготовка) */ + empty?: boolean; + /** недавно добавлен — подсветить */ + isNew?: boolean; + note: string; +} + +export const CATEGORY_LABEL: Record = { + routing: 'Роутеры · медиа · VPN', + infra: 'Инфраструктура · мониторинг', + bots: 'Боты · панели', + sites: 'Сайты · бизнес', + knowledge: 'База знаний · прочее', +}; + +export const GITEA_BASE = 'https://git1.servaki.online'; +export const GITEA_OWNER = 'german'; + +export const repoUrl = (r: GiteaRepo) => `${GITEA_BASE}/${GITEA_OWNER}/${r.name}`; + +export const repos: GiteaRepo[] = [ + // Роутеры · медиа · VPN + { name: 'home-router', category: 'routing', branch: 'main', private: true, note: 'Домашний роутер Keenetic — конфиги' }, + { name: 'router-koval', category: 'routing', branch: 'main', private: true, note: 'Роутер Коваль' }, + { name: 'lilia-ee-media-router', category: 'routing', branch: 'main', private: true, note: 'Медиа-маршрутизация через VPN (Lilia EE)' }, + { name: 'media-projects', category: 'routing', branch: 'main', private: true, note: 'Медиа-проекты' }, + { name: 'vpn-clients', category: 'routing', branch: 'master', private: true, note: 'Клиентские VPN-конфиги' }, + { name: 'vpn-vps-ruslan', category: 'routing', branch: 'main', private: true, note: 'VPN VPS (Руслан)' }, + { name: 'mesh1-status', category: 'routing', branch: 'main', private: true, note: 'Статус mesh1' }, + { name: 'mesh2-status', category: 'routing', branch: 'main', private: true, note: 'Статус mesh2 (Тэмпельхоф)' }, + + // Инфраструктура · мониторинг + { name: 'servaki-infra', category: 'infra', branch: 'main', private: true, note: 'WireGuard, Aspia, Excalidraw, лендинги' }, + { name: 'ops-configs', category: 'infra', branch: 'main', private: true, note: 'Бэкапы конфигов (ops-agent)' }, + { name: 'servaki-monitor', category: 'infra', branch: 'main', private: true, note: 'Этот дашборд (энерго/GPS/доступ/сеть/smart)' }, + { name: 'homeassistant-backup', category: 'infra', branch: 'main', private: true, note: 'Бэкап Home Assistant' }, + { name: 'tempelhoff-pbx', category: 'infra', branch: 'main', private: true, note: 'PBX (телефония) Тэмпельхоф' }, + { name: 'gate-web-relay', category: 'infra', branch: 'main', private: true, note: 'Web-реле шлюза' }, + + // Боты · панели + { name: 'orbit-bot', category: 'bots', branch: 'main', private: true, note: 'Orbit — бот' }, + { name: 'orbit-kameliya', category: 'bots', branch: 'main', private: true, note: 'Orbit — Камелия (Grafana-дашборд)' }, + { name: 'orbit-panel', category: 'bots', branch: 'main', private: true, empty: true, note: 'Orbit — панель (заготовка, пустой)' }, + { name: 'banya-bot', category: 'bots', branch: 'main', private: true, note: 'Бот бани' }, + { name: 'user-mobile-wa', category: 'bots', branch: 'main', private: true, note: 'WhatsApp-интеграция' }, + { name: 'smartlight-panel', category: 'bots', branch: 'main', private: true, note: 'Панель умного света' }, + { name: 'agro-panel', category: 'bots', branch: 'main', private: true, note: 'Агро-панель' }, + + // Сайты · бизнес + { name: 'KPgenerator', category: 'sites', branch: 'main', private: true, note: 'КП-Генератор (МПО-Информ)' }, + { name: 'mpo-inform-site', category: 'sites', branch: 'main', private: true, note: 'Сайт МПО-Информ' }, + { name: 'delodecor', category: 'sites', branch: 'main', private: true, note: 'Делодекор' }, + { name: 'Afanasov-smart', category: 'sites', branch: 'main', private: true, note: 'Афанасов — smart' }, + { name: 'user-bitarov', category: 'sites', branch: 'main', private: true, note: 'Битаров' }, + + // База знаний · прочее + { name: 'cascade', category: 'knowledge', branch: 'master', private: true, note: 'База знаний (дубль LAN-Gitea 192.168.1.120)' }, + { name: 'cascade-de-ru', category: 'knowledge', branch: 'main', private: true, note: 'Cascade DE/RU' }, + { name: 'servaki-gps-monitoring', category: 'knowledge', branch: 'main', private: true, note: 'Аналитика GPS (PR #1 в main)' }, + { name: 'matrix-migration', category: 'knowledge', branch: 'main', private: true, note: 'Миграция Matrix' }, + { name: 'claude-memory', category: 'knowledge', branch: 'main', private: true, isNew: true, note: 'Канон памяти Claude (laptop-agent) — добавлен 2026-06-30' }, + { name: 'Heritage', category: 'knowledge', branch: 'main', private: false, note: 'Публичный репозиторий' }, +]; + +export const CATEGORY_ORDER: RepoCategory[] = ['routing', 'infra', 'bots', 'sites', 'knowledge']; diff --git a/src/data/roadEvents.ts b/src/data/roadEvents.ts new file mode 100644 index 0000000..e2102fc --- /dev/null +++ b/src/data/roadEvents.ts @@ -0,0 +1,73 @@ +import type { Trip } from './trips'; + +// Детектор поездок/стоянок и дорожных событий на треке. +// Из журнала поездок (сегменты движения) выводим события: старт, остановка, +// стоянка (между поездками), превышение скорости; в проде — из сырой телеметрии FLEX по скорости/зажиганию. + +export type RoadKind = 'start' | 'stop' | 'parking' | 'speeding' | 'sensor'; + +export interface RoadEvent { + id: string; + lat: number; + lon: number; + kind: RoadKind; + label: string; + ts: string; // ISO + sub?: string; +} + +export interface StopSegment { + lat: number; + lon: number; + from: string; + to: string; + durationMin: number; + place: string; +} + +export const SPEED_LIMIT = 90; // км/ч — порог превышения + +const fmtDur = (min: number) => (min >= 60 ? `${Math.floor(min / 60)}ч ${min % 60}м` : `${min}м`); + +// Стоянки — интервалы между последовательными поездками объекта +export function detectStops(trips: Trip[]): StopSegment[] { + const sorted = [...trips].sort((a, b) => a.start.localeCompare(b.start)); + const stops: StopSegment[] = []; + for (let i = 0; i < sorted.length - 1; i++) { + const t = sorted[i]; + const next = sorted[i + 1]; + const durMin = Math.round((new Date(next.start).getTime() - new Date(t.end).getTime()) / 60000); + if (durMin >= 5) { + const p = t.route[t.route.length - 1]; + stops.push({ lat: p[0], lon: p[1], from: t.end, to: next.start, durationMin: durMin, place: t.to }); + } + } + return stops; +} + +export function detectRoadEvents(trips: Trip[]): RoadEvent[] { + const sorted = [...trips].sort((a, b) => a.start.localeCompare(b.start)); + const ev: RoadEvent[] = []; + sorted.forEach((t) => { + const r0 = t.route[0]; + const rN = t.route[t.route.length - 1]; + if (r0) ev.push({ id: t.id + '-start', lat: r0[0], lon: r0[1], kind: 'start', label: 'Начало движения', ts: t.start, sub: t.from }); + if (rN) ev.push({ id: t.id + '-stop', lat: rN[0], lon: rN[1], kind: 'stop', label: 'Остановка', ts: t.end, sub: t.to }); + if (t.maxSpeed > SPEED_LIMIT && t.route.length > 2) { + const mid = t.route[Math.floor(t.route.length * 0.6)]; + ev.push({ id: t.id + '-spd', lat: mid[0], lon: mid[1], kind: 'speeding', label: `Превышение ${t.maxSpeed} км/ч`, ts: t.start, sub: `лимит ${SPEED_LIMIT}` }); + } + }); + detectStops(trips).forEach((s, i) => { + ev.push({ id: `stop-${i}`, lat: s.lat, lon: s.lon, kind: 'parking', label: `Стоянка ${fmtDur(s.durationMin)}`, ts: s.from, sub: s.place }); + }); + return ev.sort((a, b) => a.ts.localeCompare(b.ts)); +} + +export const ROAD_KIND = { + start: { label: 'Старт', color: '#22C55E', char: '▶' }, + stop: { label: 'Стоп', color: '#EF4444', char: '■' }, + parking: { label: 'Стоянка', color: '#0EA5E9', char: 'P' }, + speeding: { label: 'Превышение', color: '#F59E0B', char: '!' }, + sensor: { label: 'Датчик', color: '#A78BFA', char: '●' }, +} as const; diff --git a/src/data/security.ts b/src/data/security.ts new file mode 100644 index 0000000..b427734 --- /dev/null +++ b/src/data/security.ts @@ -0,0 +1,203 @@ +// Демо-данные охранного пульта (ПЦН). Позже заменяются приёмной частью: +// Surgard (MLR2-DG) и SIA DC-09 → отдельный ингрис → /api/security (события + объекты). +// Здесь — реалистичная модель: охраняемые объекты, список дозвона, зоны, лента тревог. + +export type ObjState = 'armed' | 'disarmed' | 'alarm' | 'offline'; + +export interface GuardContact { + name: string; + role: string; // владелец / отв. лицо / ключедержатель + phone: string; // E.164 для tel: +} + +export interface GuardZone { + n: number; + name: string; + kind: 'burglar' | 'fire' | 'panic' | 'perimeter' | 'tech'; +} + +export interface GuardObject { + id: string; + clientId: string; + account: string; // номер объекта на ПЦН (как в приёмнике Surgard/SIA) + name: string; + address: string; + lat: number; + lon: number; + state: ObjState; + proto: 'SIA-DC09' | 'Surgard'; + channel: string; // GPRS / Ethernet / радио + cameraUrl?: string; // прямая ссылка переключения на фото/видео с объекта + photoUrl?: string; // снимок-превью (обновляется по запросу/тревоге) + contacts: GuardContact[]; // список дозвона + zones: GuardZone[]; +} + +export type AlarmStatus = 'new' | 'taken' | 'gbr' | 'closed'; + +export interface AlarmEvent { + id: string; + objId: string; + ts: string; // ISO + sia: string; // SIA-код (BA/FA/PA/OP/CL/AT/BT/TA…) + cid: string; // Surgard Contact ID (E130/E110…) + zone?: number; + user?: number; // номер пользователя (постановка/снятие) + severity: 'critical' | 'warning' | 'info' | 'ok'; + text: string; + status: AlarmStatus; +} + +// Расшифровка SIA-кодов (что реально приходит из приёмника) +export const SIA_LABEL: Record = { + BA: 'Тревога проникновение', + FA: 'Пожарная тревога', + PA: 'Тревожная кнопка (паника)', + HA: 'Нападение (Hold-up)', + TA: 'Вскрытие корпуса (tamper)', + OP: 'Снятие с охраны', + CL: 'Постановка на охрану', + AT: 'Пропадание 220В', + AR: 'Восстановление 220В', + BT: 'Разряд АКБ', + YC: 'Потеря связи с объектом', + NL: 'Тест-сигнал не получен', +}; + +// Соответствие SIA → Surgard Contact ID (для документирования приёмной части) +export const SIA_TO_CID: Record = { + BA: 'E130', + FA: 'E110', + PA: 'E120', + HA: 'E121', + TA: 'E137', + OP: 'E401', + CL: 'R401', + AT: 'E301', + AR: 'R301', + BT: 'E302', + YC: 'E354', + NL: 'E381', +}; + +const now = Date.now(); +const iso = (minAgo: number) => new Date(now - minAgo * 60000).toISOString(); + +export const guardObjects: GuardObject[] = [ + { + id: 'obj-101', + clientId: 'tempelhoff', + account: '1042', + name: 'Магазин «ЭлектроКМВ»', + address: 'Минеральные Воды, ул. Советская, 24', + lat: 44.21, + lon: 43.13, + state: 'alarm', + proto: 'Surgard', + channel: 'GPRS + Ethernet', + cameraUrl: 'https://cam.servaki.online/obj-101/snapshot', + photoUrl: 'https://placehold.co/640x360/0F172A/EF4444?text=ObjeKt+101+%E2%80%94+TREVOGA', + contacts: [ + { name: 'Галицкий И. П.', role: 'Владелец', phone: '+79620151414' }, + { name: 'Сторож (ночь)', role: 'Ключедержатель', phone: '+79280000001' }, + { name: 'Управляющий', role: 'Отв. лицо', phone: '+79280000002' }, + ], + zones: [ + { n: 1, name: 'Входная дверь', kind: 'burglar' }, + { n: 2, name: 'Торговый зал (объём)', kind: 'burglar' }, + { n: 3, name: 'Тревожная кнопка касса', kind: 'panic' }, + { n: 4, name: 'Дымовой пожарный', kind: 'fire' }, + ], + }, + { + id: 'obj-102', + clientId: 'tempelhoff', + account: '1043', + name: 'Склад «База Весовая»', + address: 'Ульяновка, ул. Весовая, 1', + lat: 44.07, + lon: 43.04, + state: 'armed', + proto: 'SIA-DC09', + channel: 'Ethernet', + cameraUrl: 'https://cam.servaki.online/obj-102/snapshot', + photoUrl: 'https://placehold.co/640x360/0F172A/22C55E?text=ObjeKt+102+%E2%80%94+pod+ohranoj', + contacts: [ + { name: 'Завскладом', role: 'Отв. лицо', phone: '+79280000010' }, + { name: 'Охрана', role: 'Ключедержатель', phone: '+79280000011' }, + ], + zones: [ + { n: 1, name: 'Ворота', kind: 'perimeter' }, + { n: 2, name: 'Периметр (ИК)', kind: 'perimeter' }, + { n: 3, name: 'Кабинет', kind: 'burglar' }, + ], + }, + { + id: 'obj-103', + clientId: 'tempelhoff', + account: '1051', + name: 'Дом Галицкого', + address: 'Минеральные Воды, пер. Тихий, 7', + lat: 44.2, + lon: 43.11, + state: 'disarmed', + proto: 'SIA-DC09', + channel: 'GPRS', + cameraUrl: 'https://cam.servaki.online/obj-103/snapshot', + photoUrl: 'https://placehold.co/640x360/0F172A/94A3B8?text=ObjeKt+103+%E2%80%94+snjat', + contacts: [ + { name: 'Галицкий И. П.', role: 'Владелец', phone: '+79620151414' }, + { name: 'Супруга', role: 'Отв. лицо', phone: '+79280000020' }, + ], + zones: [ + { n: 1, name: 'Калитка', kind: 'perimeter' }, + { n: 2, name: 'Холл', kind: 'burglar' }, + { n: 3, name: 'Тревожная кнопка', kind: 'panic' }, + ], + }, + { + id: 'obj-104', + clientId: 'tempelhoff', + account: '1077', + name: 'Аптека №3', + address: 'Георгиевск, ул. Ленина, 110', + lat: 44.15, + lon: 43.47, + state: 'offline', + proto: 'Surgard', + channel: 'GPRS', + photoUrl: 'https://placehold.co/640x360/0F172A/F59E0B?text=ObjeKt+104+%E2%80%94+net+svjazi', + contacts: [ + { name: 'Заведующая', role: 'Отв. лицо', phone: '+79280000030' }, + ], + zones: [ + { n: 1, name: 'Вход', kind: 'burglar' }, + { n: 2, name: 'Сейфовая (объём)', kind: 'burglar' }, + ], + }, +]; + +export const alarmFeed: AlarmEvent[] = [ + { id: 'a1', objId: 'obj-101', ts: iso(2), sia: 'BA', cid: 'E130', zone: 2, severity: 'critical', text: 'Тревога: торговый зал (объёмный датчик)', status: 'new' }, + { id: 'a2', objId: 'obj-101', ts: iso(2), sia: 'PA', cid: 'E120', zone: 3, severity: 'critical', text: 'Тревожная кнопка у кассы', status: 'new' }, + { id: 'a3', objId: 'obj-104', ts: iso(14), sia: 'YC', cid: 'E354', severity: 'warning', text: 'Потеря связи с объектом (нет теста)', status: 'taken' }, + { id: 'a4', objId: 'obj-102', ts: iso(33), sia: 'CL', cid: 'R401', user: 5, severity: 'info', text: 'Объект взят под охрану (польз. 5)', status: 'closed' }, + { id: 'a5', objId: 'obj-103', ts: iso(61), sia: 'OP', cid: 'E401', user: 1, severity: 'info', text: 'Объект снят с охраны (польз. 1)', status: 'closed' }, + { id: 'a6', objId: 'obj-102', ts: iso(120), sia: 'AT', cid: 'E301', severity: 'warning', text: 'Пропадание сети 220В, переход на АКБ', status: 'closed' }, +]; + +// Дежурные группы быстрого реагирования (ГБР) +export interface Brigade { + id: string; + name: string; + crew: string; + phone: string; + status: 'free' | 'enroute' | 'onsite'; + lat: number; + lon: number; +} + +export const brigades: Brigade[] = [ + { id: 'gbr-1', name: 'ГБР-1 «Минводы»', crew: 'Экипаж 2 чел.', phone: '+79280000100', status: 'free', lat: 44.2, lon: 43.12 }, + { id: 'gbr-2', name: 'ГБР-2 «Георгиевск»', crew: 'Экипаж 2 чел.', phone: '+79280000101', status: 'enroute', lat: 44.16, lon: 43.46 }, +]; diff --git a/src/data/trips.ts b/src/data/trips.ts new file mode 100644 index 0000000..d1b3fd1 --- /dev/null +++ b/src/data/trips.ts @@ -0,0 +1,28 @@ +// Журнал перемещений (поездок). Демо-данные; в проде — Traccar /reports/trips. +export interface Trip { + id: string; + entityId: string; + start: string; // ISO + end: string; // ISO + from: string; + to: string; + distanceKm: number; + durationMin: number; + maxSpeed: number; + avgSpeed: number; + route: Array<[number, number]>; // полилиния маршрута для карты +} + +const h = (n: number) => new Date(Date.now() - n * 3600000).toISOString(); + +export const trips: Trip[] = [ + { id: 't1', entityId: 'veh-123', start: h(6), end: h(5.2), from: 'База «Тэмпельхоф»', to: 'Пятигорск, рынок', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41, route: [[44.07, 43.05], [44.061, 43.058], [44.052, 43.064], [44.043, 43.071]] }, + { id: 't2', entityId: 'veh-123', start: h(4.5), end: h(3.7), from: 'Пятигорск, рынок', to: 'Ессентуки, склад', distanceKm: 22.1, durationMin: 49, maxSpeed: 84, avgSpeed: 38, route: [[44.043, 43.071], [44.05, 43.0], [44.05, 42.93], [44.045, 42.86]] }, + { id: 't3', entityId: 'veh-123', start: h(2), end: h(1.4), from: 'Ессентуки, склад', to: 'База «Тэмпельхоф»', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51, route: [[44.045, 42.86], [44.052, 42.95], [44.062, 43.0], [44.07, 43.05]] }, + { id: 't4', entityId: 'veh-204', start: h(8), end: h(6.5), from: 'Поле №3', to: 'МТМ (ремонт)', distanceKm: 6.2, durationMin: 90, maxSpeed: 24, avgSpeed: 9, route: [[44.02, 43.11], [44.025, 43.1], [44.03, 43.095]] }, + { id: 't5', entityId: 'veh-204', start: h(3), end: h(2.2), from: 'МТМ', to: 'Поле №5', distanceKm: 8.8, durationMin: 48, maxSpeed: 28, avgSpeed: 12, route: [[44.03, 43.095], [44.038, 43.085], [44.045, 43.08]] }, +]; + +export function tripsOf(entityId: string): Trip[] { + return trips.filter((t) => t.entityId === entityId); +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..4a51757 --- /dev/null +++ b/src/index.css @@ -0,0 +1,69 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + /* Светлая тема (по умолчанию переменные :root) */ + :root { + --bg: 241 245 249; + --surface: 255 255 255; + --surface-2: 248 250 252; + --muted: 226 232 240; + --border: 226 232 240; + --fg: 15 23 42; + --fg-muted: 100 116 139; + color-scheme: light; + } + /* Тёмная тема (OLED) */ + .dark { + --bg: 15 23 42; + --surface: 17 28 50; + --surface-2: 22 34 60; + --muted: 39 47 66; + --border: 39 52 75; + --fg: 248 250 252; + --fg-muted: 148 163 184; + color-scheme: dark; + } + + html, + body, + #root { + height: 100%; + } + body { + @apply bg-bg text-fg font-sans antialiased; + font-feature-settings: 'tnum' 1, 'cv01' 1; + } + .tnum { + font-variant-numeric: tabular-nums; + } + *:focus-visible { + @apply outline-none ring-2 ring-accent/70 ring-offset-2 ring-offset-bg; + } + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-thumb { + @apply bg-border rounded-full; + } +} + +/* Анимированный пунктир связей на карте (поток данных по линку) */ +.link-flow { + stroke-dasharray: 8 10; + animation: linkflow 1s linear infinite; +} +@keyframes linkflow { + to { + stroke-dashoffset: -18; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} diff --git a/src/lib/report.ts b/src/lib/report.ts new file mode 100644 index 0000000..bd8b2f9 --- /dev/null +++ b/src/lib/report.ts @@ -0,0 +1,47 @@ +import type { User } from '@/core/auth'; + +export interface ReportSpec { + title: string; + columns: string[]; + rows: Array>; + note?: string; +} + +const esc = (s: string | number): string => + String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c] as string); + +// Выгрузка отчёта в PDF через печать браузера (кириллица корректна, без тяжёлых зависимостей). +// Шапка отчёта: раздел, дата генерации, пользователь-инициатор. +export function exportPdf(spec: ReportSpec, sectionTitle: string, user: User): void { + const now = new Date().toLocaleString('ru-RU'); + const head = spec.columns.map((c) => `${esc(c)}`).join(''); + const body = spec.rows + .map((r) => `${r.map((c) => `${esc(c)}`).join('')}`) + .join(''); + const html = `${esc(spec.title)} + +

${esc(spec.title)}

+
Раздел: ${esc(sectionTitle)}  ·  Сформирован: ${esc(now)}  ·  Инициатор: ${esc(user.name)} (${esc(user.role || '')})
+ ${head}${body}
+ ${spec.note ? `

${esc(spec.note)}

` : ''} +
Железный Егорин · Смартпанель · отчёт сформирован автоматически
+ +`; + const w = window.open('', '_blank', 'width=920,height=720'); + if (!w) return; // popup заблокирован — тихо выходим + w.document.write(html); + w.document.close(); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..c475719 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,17 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +/** Форматирование числа с фикс. знаками и неразрывным пробелом разрядов. + * Null-безопасно: на живых данных значение может отсутствовать (null/undefined/NaN) — + * в этом случае возвращаем «—» вместо падения рендера (был краш /gps: null.toLocaleString). */ +export function fmt(n: number | null | undefined, digits = 1): string { + if (n == null || typeof n !== 'number' || !Number.isFinite(n)) return '—'; + return n.toLocaleString('ru-RU', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..c68f490 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/src/modules/access/AccessModule.tsx b/src/modules/access/AccessModule.tsx new file mode 100644 index 0000000..589494f --- /dev/null +++ b/src/modules/access/AccessModule.tsx @@ -0,0 +1,247 @@ +import type { ComponentType } from 'react'; +import { ShieldCheck, Check, Info, Zap, Cpu, Navigation, Network, Siren, Eye, Pencil, FileText } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { cn } from '@/lib/utils'; +import { useAuth } from '@/core/store'; +import { SECTIONS, SECTION_LABEL, ALL_CAPS, CAP_LABEL, type Capability, type SectionId, type User } from '@/core/auth'; + +// Иконка и пояснение по каждому разделу (комментарии полей) +const SECTION_ICON: Record> = { + energy: Zap, + smart: Cpu, + gps: Navigation, + netinfra: Network, + security: Siren, +}; +const SECTION_COMMENT: Record = { + energy: 'Электроснабжение: PV / АКБ (SOC) / нагрузка, аварии питания, выработка.', + smart: 'Устройства Wiren Board: реле, диммеры, датчики (вкл/выкл, уровни).', + gps: 'Транспорт и стационарные объекты: позиции, треки, поездки, геозоны.', + netinfra: 'Узлы и каналы сети mesh2: связанность, телеметрия, фото с камер.', + security: 'Охранный пульт (ПЦН): приём тревог Surgard/SIA, ГБР, дозвон, фото.', +}; +const CAP_ICON: Record> = { + view: Eye, + edit: Pencil, + reports: FileText, +}; +const CAP_COMMENT: Record = { + view: 'Просмотр — видеть раздел и данные. Базовое право (без него раздел скрыт).', + edit: 'Правка — менять состояние: тумблеры, постановка/снятие, редактирование объектов.', + reports: 'Отчёты — журналы и выгрузка CSV/PDF.', +}; + +// Чередующийся фон колонок-разделов для визуальной сегментации +const SECTION_BG = ['bg-transparent', 'bg-bg/30']; + +function CapBox({ on, editable, onToggle, title }: { on: boolean; editable: boolean; onToggle: () => void; title: string }) { + return ( + + ); +} + +function SectionGroup({ + u, + section, + editable, + alt, + onToggle, +}: { + u: User; + section: SectionId; + editable: boolean; + alt: number; + onToggle: (cap: Capability) => void; +}) { + const a = u.access[section]; + return ( + +
+ {ALL_CAPS.map((cap) => ( + onToggle(cap)} + title={`${SECTION_LABEL[section]} · ${CAP_LABEL[cap]} — ${CAP_COMMENT[cap]}`} + /> + ))} +
+
+ {a ? ( + + {a.objects === 'all' ? 'все объекты' : `${a.objects.length} из набора`} + + ) : ( + нет доступа + )} +
+ + ); +} + +export function AccessModule() { + const { user: current, users, setUser, toggleCap } = useAuth(); + const isAdmin = !!current.admin; + + return ( +
+ {isAdmin ? 'админ · правка прав' : 'только просмотр'}} + /> + + + +
+ + + {/* Ряд 1 — разделы (иконка + название + комментарий) */} + + + {SECTIONS.map((s, i) => { + const Icon = SECTION_ICON[s]; + return ( + + ); + })} + + {/* Ряд 2 — поля прав (просмотр/правка/отчёты) */} + + {SECTIONS.map((s, i) => ( + + ))} + + + + {users.map((u) => ( + + + {SECTIONS.map((s, i) => ( + toggleCap(u.id, s, cap)} /> + ))} + + + ))} + +
+ Пользователь + +
+ + {SECTION_LABEL[s]} + +
+
+
+
+ {ALL_CAPS.map((cap) => { + const CI = CAP_ICON[cap]; + return ( + + + + ); + })} +
+
+
{u.name}
+
{u.role}
+ {u.admin && ( + + админ + + )} +
+ {u.id === current.id ? ( + + вы + + ) : ( + + )} +
+
+
+ + {/* Легенда — комментарии полей */} +
+ + +
+ {ALL_CAPS.map((cap) => { + const CI = CAP_ICON[cap]; + return ( +
+ + + +
+ {CAP_LABEL[cap]} + — {CAP_COMMENT[cap]} +
+
+ ); + })} +
+
+ + +
+ {SECTIONS.map((s) => { + const Icon = SECTION_ICON[s]; + return ( +
+ + + +
+ {SECTION_LABEL[s]} + — {SECTION_COMMENT[s]} +
+
+ ); + })} +
+
+
+ +
+ +

+ Модель RBAC: у каждого пользователя — набор разделов с уровнями (просмотр/правка/отчёты) и областью объектов + (все либо часть). Меню, объекты, кнопки правки и выгрузки отчётов фильтруются по правам. Администратор выдаёт + доступ чекбоксами (view — базовый: правка/отчёты требуют просмотр). В проде права + приходят с бэкенда по clientId (мультиарендность). + {!isAdmin && ' Правка прав доступна только администратору.'} +

+
+
+ ); +} diff --git a/src/modules/admin/AdminModule.tsx b/src/modules/admin/AdminModule.tsx new file mode 100644 index 0000000..a65aca8 --- /dev/null +++ b/src/modules/admin/AdminModule.tsx @@ -0,0 +1,277 @@ +import { useEffect, useState } from 'react'; +import { Gauge, Users, Activity, Cpu, Clock, UserPlus, Trash2, Ban, Unlock, ShieldAlert } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { KpiCard } from '@/components/ui/KpiCard'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { cn } from '@/lib/utils'; +import { apiGet, apiPost, apiDelete } from '@/api/client'; + +interface Stats { + onlineTotal: number; + bySection: Record; + reqPerMin: number; + reqTotal: number; + rssMb: number; + uptimeSec: number; +} +interface OnlineUser { + id: string; + name: string; + section: string; + secAgo: number; +} +interface Act { + ts: string; + name: string; + section: string; + action: string; +} +interface AUser { + id: string; + name: string; + email: string; + role: string; + admin: boolean; + clientId: string; + blocked: { from?: string | null; until?: string | null } | null; + sections: string[]; +} + +const dt = (iso: string) => new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +const upt = (s: number) => `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`; + +function BlockRow({ u, onChange }: { u: AUser; onChange: () => void }) { + const [from, setFrom] = useState(''); + const [until, setUntil] = useState(''); + const blocked = !!u.blocked; + const block = async () => { + await apiPost(`/admin/users/${u.id}/block`, { from: from ? `${from}T00:00:00` : null, until: until ? `${until}T23:59:59` : null }); + onChange(); + }; + const unblock = async () => { + await apiPost(`/admin/users/${u.id}/unblock`, {}); + onChange(); + }; + const unblockFrom = async () => { + if (!until) return; + await apiPost(`/admin/users/${u.id}/block`, { until: `${until}T23:59:59` }); + onChange(); + }; + return ( +
+ {blocked ? ( + <> + + заблокирован{u.blocked?.until ? ` до ${new Date(u.blocked.until).toLocaleDateString('ru-RU')}` : ''} + + + + ) : ( + <> + setFrom(e.target.value)} title="блок с даты" className="rounded-md border border-border bg-surface-2 px-1.5 py-1 text-[11px] text-fg" /> + setUntil(e.target.value)} title="блок до даты (разморозка с даты)" className="rounded-md border border-border bg-surface-2 px-1.5 py-1 text-[11px] text-fg" /> + + {until && ( + + )} + + )} +
+ ); +} + +export function AdminModule() { + const [stats, setStats] = useState(null); + const [online, setOnline] = useState([]); + const [activity, setActivity] = useState([]); + const [users, setUsers] = useState([]); + const [nEmail, setNEmail] = useState(''); + const [nName, setNName] = useState(''); + const [traffic, setTraffic] = useState>({ gps: [], pcn: [], gw: [] }); + + const tickTraffic = () => + setTraffic((prev) => { + const base: Record = { gps: 120, pcn: 40, gw: 90 }; + const next: Record = {}; + for (const k of Object.keys(base)) { + const spike = Math.random() < 0.07 ? 400 + Math.random() * 600 : 0; // редкий всплеск = «атака» + const v = Math.max(0, Math.round(base[k] + (Math.random() - 0.5) * 60 + spike)); + next[k] = [...(prev[k] || []), v].slice(-24); + } + return next; + }); + + const loadLive = () => { + apiGet('/admin/stats').then(setStats).catch(() => {}); + apiGet('/admin/online').then(setOnline).catch(() => {}); + apiGet('/admin/activity').then((a) => setActivity(a.slice(0, 40))).catch(() => {}); + tickTraffic(); + }; + const loadUsers = () => apiGet('/admin/users').then(setUsers).catch(() => {}); + useEffect(() => { + loadLive(); + loadUsers(); + const t = setInterval(loadLive, 5000); + return () => clearInterval(t); + }, []); + + const addUser = async () => { + if (!nEmail && !nName) return; + await apiPost('/admin/users', { email: nEmail.trim(), name: nName.trim() || nEmail.trim() }); + setNEmail(''); + setNName(''); + loadUsers(); + }; + const delUser = async (id: string) => { + await apiDelete(`/admin/users/${id}`); + loadUsers(); + }; + + return ( +
+ live · обновление 5с} + /> + +
+ + + 400 ? 'warn' : 'muted'} /> + + +
+ + {/* Трафик от ингрисов — детект атак (всплеск = возможная атака) */} + + live} /> +
+ {[ + { id: 'gps', name: 'GPS-ингрис · ru2', node: '87.242.102.17 :2000 FLEX' }, + { id: 'pcn', name: 'ПЦН-ингрис · ru3', node: '45.151.31.222 :5111/5112' }, + { id: 'gw', name: 'Шлюз/резерв · vm-mts1', node: '2.59.83.139 :2000/1883' }, + ].map((n) => { + const hist = traffic[n.id] || []; + const cur = hist[hist.length - 1] ?? 0; + const max = Math.max(200, ...hist); + const status = cur >= 500 ? 'crit' : cur >= 220 ? 'warn' : 'ok'; + const label = cur >= 500 ? 'ВОЗМОЖНА АТАКА' : cur >= 220 ? 'повышенная' : 'норма'; + return ( +
+
+ {n.name} + {label} +
+
{n.node}
+
+ {hist.map((v, i) => ( +
= 500 ? 'bg-crit' : v >= 220 ? 'bg-warn' : 'bg-accent')} style={{ height: `${Math.max(2, (v / max) * 34)}px`, opacity: 0.5 + 0.5 * (i / Math.max(1, hist.length - 1)) }} /> + ))} +
+
+ {cur} + пакетов/мин +
+
+ ); + })} +
+

Демо-метрики. В проде — реальный счётчик сообщений шлюза по префиксам топиков (navtelecom/ = GPS, guard/ = ПЦН) + порог авто-тревоги.

+ + +
+ + +
+ {Object.entries(stats?.bySection || {}).map(([s, n]) => ( + + {s}: {n} + + ))} + {!Object.keys(stats?.bySection || {}).length && нет онлайн} +
+
+ {online.map((o) => ( +
+ {o.name.slice(0, 2).toUpperCase()} + {o.name} + {o.section} + {o.secAgo}с назад +
+ ))} +
+
+ + + +
+ {activity.map((a, i) => ( +
+ + {dt(a.ts)} + {a.name} + {a.section} +
+ ))} +
+
+
+ + + +
+
+ setNEmail(e.target.value)} placeholder="email" className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted focus:border-accent" /> + setNName(e.target.value)} placeholder="имя (опц.)" className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted focus:border-accent" /> + +
+
+ + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
ПользовательАрендаторРазделыБлокировка +
+
{u.name}
+
{u.email || u.id} {u.admin && '· админ'}
+
{u.clientId}{u.sections.join(', ') || '—'} + + + {!u.admin && ( + + )} +
+
+
+
+
+ ); +} diff --git a/src/modules/cabinet/CabinetModule.tsx b/src/modules/cabinet/CabinetModule.tsx new file mode 100644 index 0000000..22a7b54 --- /dev/null +++ b/src/modules/cabinet/CabinetModule.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from 'react'; +import { Wallet, CreditCard, Building2, Check, Smartphone, Users, UserPlus, Trash2 } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { KpiCard } from '@/components/ui/KpiCard'; +import { cn, fmt } from '@/lib/utils'; +import { apiGet, apiPost, apiDelete } from '@/api/client'; +import { useAuth } from '@/core/store'; + +interface Billing { + clientId: string; + balance: number; + currency: string; + plan: string; + devices: number; +} + +const AMOUNTS = [300, 500, 1000, 3000]; + +export function CabinetModule() { + const user = useAuth((s) => s.user); + const [b, setB] = useState(null); + const [amount, setAmount] = useState(500); + const [busy, setBusy] = useState(false); + const [links, setLinks] = useState([]); + const [newEmail, setNewEmail] = useState(''); + + useEffect(() => { + apiGet('/billing').then(setB).catch(() => {}); + apiGet('/cabinet/links').then(setLinks).catch(() => {}); + }, []); + + const addLink = async () => { + const e = newEmail.trim(); + if (!e) return; + const r = await apiPost('/cabinet/links', { email: e }); + setLinks(r || []); + setNewEmail(''); + }; + const removeLink = async (email: string) => { + const r = await apiDelete(`/cabinet/links/${encodeURIComponent(email)}`); + setLinks(r || []); + }; + + const topup = async (method: 'yoomoney' | 'sberbank') => { + setBusy(true); + try { + const r = await apiPost<{ payUrl: string }>('/billing/topup', { amount, method }); + if (r?.payUrl) window.open(r.payUrl, '_blank', 'noopener'); + } finally { + setBusy(false); + } + }; + + return ( +
+ кабинет: {user.clientId}} /> + +
+ + + + +
+ +
+ + +
+
+ {AMOUNTS.map((a) => ( + + ))} + setAmount(Math.max(0, Number(e.target.value)))} + className="tnum w-28 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent" + /> +
+ +
+ + +
+

⚠️ Демо-заглушка: реальное списание не происходит, открывается тестовая форма оплаты. Боевая интеграция ЮMoney/Сбербанк — по плану.

+
+
+ + + +
+ {['GPS-мониторинг своего автопарка', 'Привязка трекеров по IMEI', 'Треки, поездки, геозоны', 'Отчёты CSV/PDF', 'Уведомления и «Рация»'].map((f) => ( +
+ {f} +
+ ))} +
+ Тариф {b?.plan ?? '—'}. Расширенные модули (Энерго / Смарт / Сеть) подключает администратор. +
+
+
+
+ + {/* Связанные аккаунты — общий кабинет для нескольких Яндекс-входов */} + + Связанные аккаунты} + desc="добавьте e-mail коллеги — при входе через Яндекс он попадёт в этот же кабинет (общие объекты и устройства)" + right={{links.length}} + /> +
+
+ setNewEmail(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addLink()} + type="email" + placeholder="e-mail Яндекс-аккаунта" + className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted focus:border-accent" + /> + +
+ {links.length === 0 ? ( +

Связанных аккаунтов нет. Кабинет доступен только вам ({user.name}).

+ ) : ( +
+ {links.map((e) => ( +
+ {e.slice(0, 2).toUpperCase()} + {e} + кабинет: {user.clientId} + +
+ ))} +
+ )} +

Связанный аккаунт видит те же объекты, устройства и данные, но не является администратором. Отвязка — кнопкой корзины.

+
+
+
+ ); +} diff --git a/src/modules/devices/DevicesModule.tsx b/src/modules/devices/DevicesModule.tsx new file mode 100644 index 0000000..d66563f --- /dev/null +++ b/src/modules/devices/DevicesModule.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react'; +import { Smartphone, Plus, Trash2, Info } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { apiGet, apiPost, apiDelete } from '@/api/client'; +import { useAuth } from '@/core/store'; + +interface Device { + imei: string; + name: string; +} + +export function DevicesModule() { + const user = useAuth((s) => s.user); + const [devices, setDevices] = useState([]); + const [imei, setImei] = useState(''); + const [name, setName] = useState(''); + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + + const refresh = () => apiGet('/devices').then(setDevices).catch(() => setDevices([])); + useEffect(() => { + refresh(); + }, []); + + const bind = async () => { + setErr(''); + if (!/^\d{6,20}$/.test(imei.trim())) { + setErr('IMEI — только цифры (6–20 знаков)'); + return; + } + setBusy(true); + try { + await apiPost('/devices/bind', { imei: imei.trim(), name: name.trim() || undefined }); + setImei(''); + setName(''); + await refresh(); + } catch { + setErr('Не удалось привязать устройство'); + } finally { + setBusy(false); + } + }; + const remove = async (i: string) => { + await apiDelete(`/devices/${i}`); + refresh(); + }; + + return ( +
+ кабинет: {user.clientId}} + /> +
+ + +
+
+ setImei(e.target.value)} + placeholder="IMEI трекера" + inputMode="numeric" + className="tnum flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted focus:border-accent" + /> + setName(e.target.value)} + placeholder="Название (опционально)" + className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted focus:border-accent" + /> + +
+ {err &&

{err}

} + +
+ {devices.length === 0 ? ( +

+ Устройств пока нет. Привяжите трекер по IMEI — данные появятся после первого сеанса связи. +

+ ) : ( + devices.map((d) => ( +
+
+ +
+
+
{d.name}
+
IMEI {d.imei}
+
+ +
+ )) + )} +
+
+
+ + + +
+

+ В конфигураторе Navtelecom укажите телематический сервер: +

+
+ домен: gps.servaki.online +
+ порт: 2000 +
+ протокол: FLEX +
+

+ После привязки IMEI входящие данные попадут в ваш кабинет ({user.clientId}) и появятся в GPS-мониторинге — только вам. +

+
+
+
+
+ ); +} diff --git a/src/modules/energy/EnergyModule.tsx b/src/modules/energy/EnergyModule.tsx new file mode 100644 index 0000000..e38c918 --- /dev/null +++ b/src/modules/energy/EnergyModule.tsx @@ -0,0 +1,224 @@ +import { useState } from 'react'; +import { Zap, Sun, BatteryCharging, Gauge, Thermometer, TriangleAlert, ChevronDown, ShieldCheck } from 'lucide-react'; +import type { MonitorEvent } from '@/core/types'; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { PageHeader } from '@/components/PageHeader'; +import { KpiCard } from '@/components/ui/KpiCard'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { Collapsible, PropList } from '@/components/ui/Collapsible'; +import { energyByObject, energyObjects, energyKpi, events } from '@/data/mock'; +import { fmt } from '@/lib/utils'; +import { useAuth } from '@/core/store'; +import { can, isObjectAllowed, SECTION_LABEL } from '@/core/auth'; +import { ExportPdfButton } from '@/components/ExportPdfButton'; + +function ChartTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null; + return ( +
+
{label}
+ {payload.map((p: any) => ( +
+ + {p.name}: + {fmt(p.value, 0)} +
+ ))} +
+ ); +} + +// Пиктограмма аварий: клик раскрывает контекст; «Аварий нет» в сером поле при отсутствии +function AlertsStrip({ alerts }: { alerts: MonitorEvent[] }) { + const [openId, setOpenId] = useState(null); + if (!alerts.length) { + return ( +
+ Аварий нет +
+ ); + } + return ( + + {alerts.length}} /> +
+ {alerts.map((a) => ( +
+ +
+
+
+ +
+
+
+
+ ))} +
+
+ ); +} + +export function EnergyModule() { + const user = useAuth((s) => s.user); + const canEdit = can(user, 'energy', 'edit'); + const canReports = can(user, 'energy', 'reports'); + const powerAlerts = events.filter( + (e) => (e.type.startsWith('power') || e.type.includes('batt')) && isObjectAllowed(user, 'energy', e.entityId), + ); + + return ( +
+ + {!canEdit && только просмотр} + {canReports && ( + ({ + title: 'Энергомониторинг — сводка', + columns: ['Параметр', 'Значение'], + rows: [ + ['Мощность PV, кВт', fmt(energyKpi.pvPower)], + ['Заряд АКБ (SOC), %', energyKpi.soc], + ['Напряжение АКБ, В', fmt(energyKpi.battU)], + ['Ток АКБ, А', fmt(energyKpi.battI)], + ['Температура BMS, °C', fmt(energyKpi.bmsTemp)], + ['Выработка за сутки, кВт·ч', fmt(energyKpi.pvToday)], + ], + note: `Активных энергособытий: ${powerAlerts.length}. Источник: Sunways STH → Wiren Board → InfluxDB.`, + })} + /> + )} + демо-моки +
+ } + /> + +
+ + + + + + +
+ +
+ + 24 ч} /> +
+ + + + + + } /> + + {energyObjects.map((o) => ( + + ))} + + +
+
+ + + +
+ + + + + + } /> + + {energyObjects.map((o) => ( + + ))} + + +
+
+
+ + + + {/* Ролл-панели подробных свойств */} +
+ + } + > + + + + + + } + > + + + +
+
+ ); +} diff --git a/src/modules/geo/GeoModule.tsx b/src/modules/geo/GeoModule.tsx new file mode 100644 index 0000000..190e8b8 --- /dev/null +++ b/src/modules/geo/GeoModule.tsx @@ -0,0 +1,151 @@ +import { useMemo, useState } from 'react'; +import { MapPinned, Plus, Pencil, Trash2, X, Check, Circle } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { MapFrame } from '@/components/MapFrame'; +import { MapView } from '@/modules/gps/MapView'; +import { GpsTabs } from '@/modules/gps/GpsTabs'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { cn } from '@/lib/utils'; +import { useAuth } from '@/core/store'; +import { can } from '@/core/auth'; +import { useGpsData } from '@/modules/gps/useGpsData'; +import { useGeofences, emptyZone, distM, ZONE_PALETTE, type Geofence } from '@/data/geofences'; + +function ZoneEditor({ zone, onClose }: { zone: Geofence; onClose: () => void }) { + const upsert = useGeofences((s) => s.upsert); + const [z, setZ] = useState(zone); + return ( +
+
+
+
+
Геозона
+ +
+
+
+ + setZ({ ...z, name: e.target.value })} className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent" /> +
+
+
+ + setZ({ ...z, lat: parseFloat(e.target.value.replace(',', '.')) || 0 })} inputMode="decimal" className="tnum w-full rounded-lg border border-border bg-surface-2 px-2 py-2 text-sm text-fg outline-none focus:border-accent" /> +
+
+ + setZ({ ...z, lon: parseFloat(e.target.value.replace(',', '.')) || 0 })} inputMode="decimal" className="tnum w-full rounded-lg border border-border bg-surface-2 px-2 py-2 text-sm text-fg outline-none focus:border-accent" /> +
+
+ + setZ({ ...z, radius: Math.max(10, Number(e.target.value) || 0) })} type="number" className="tnum w-full rounded-lg border border-border bg-surface-2 px-2 py-2 text-sm text-fg outline-none focus:border-accent" /> +
+
+
+ +
+ {ZONE_PALETTE.map((c) => ( +
+
+
+
+ + +
+
+
+ ); +} + +export function GeoModule() { + const user = useAuth((s) => s.user); + const canEdit = can(user, 'gps', 'edit'); + const { objs } = useGpsData(user); + const { zones, remove, toggle } = useGeofences(); + const [edit, setEdit] = useState(null); + const [sel, setSel] = useState(''); + + const circles = useMemo( + () => zones.filter((z) => z.enabled).map((z) => ({ id: z.id, lat: z.lat, lon: z.lon, radius: z.radius, color: z.color, name: z.name })), + [zones], + ); + const inZone = (z: Geofence) => objs.filter((o) => o.lat != null && o.lon != null && z.enabled && distM(o.lat, o.lon, z.lat, z.lon) <= z.radius); + + return ( +
+ + локально · демо + {canEdit && ( + + )} +
+ } + /> + + + +
+ + +
+ {zones.map((z) => { + const inside = inZone(z); + return ( +
+
+ + + + {canEdit && ( + <> + + + + )} +
+
+ R {z.radius} м · {z.lat.toFixed(3)}, {z.lon.toFixed(3)} + в зоне: {inside.length} +
+ {!!inside.length && ( +
{inside.map((o) => o.name).join(', ')}
+ )} +
+ ); + })} + {!zones.length &&
Зон нет. Добавьте зону.
} +
+
+ + + +
+ + {(full) => ( + + )} + +
+
+
+ + {edit && setEdit(null)} />} +
+ ); +} diff --git a/src/modules/gps/EventsModule.tsx b/src/modules/gps/EventsModule.tsx new file mode 100644 index 0000000..abd3ab3 --- /dev/null +++ b/src/modules/gps/EventsModule.tsx @@ -0,0 +1,95 @@ +import { useMemo, useState } from 'react'; +import { History, Search, Clock } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, Badge } from '@/components/ui/primitives'; +import { cn } from '@/lib/utils'; +import { GpsTabs } from './GpsTabs'; +import { events as mockEvents } from '@/data/mock'; +import { alarmFeed, guardObjects, SIA_LABEL } from '@/data/security'; +import type { Severity } from '@/core/types'; + +interface Row { + ts: string; + callsign: string; + section: string; + type: string; + text: string; + severity: Severity; +} + +const SEV: { key: Severity | 'all'; label: string }[] = [ + { key: 'all', label: 'Все' }, + { key: 'critical', label: 'Критические' }, + { key: 'warning', label: 'Предупреждения' }, + { key: 'info', label: 'Инфо' }, +]; +const dt = (iso: string) => new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +const DOT: Record = { critical: 'bg-crit', warning: 'bg-warn', info: 'bg-info', ok: 'bg-ok' }; + +export function EventsModule() { + const [sev, setSev] = useState('all'); + const [q, setQ] = useState(''); + + const rows: Row[] = useMemo(() => { + const fromMock: Row[] = mockEvents.map((e) => ({ ts: e.ts, callsign: e.callsign, section: 'gps/энерго', type: e.type, text: e.text, severity: e.severity })); + const fromAlarms: Row[] = alarmFeed.map((a) => { + const o = guardObjects.find((g) => g.id === a.objId); + return { ts: a.ts, callsign: o?.name ?? a.objId, section: 'охрана', type: `${a.sia}·${a.cid}`, text: a.text || (SIA_LABEL[a.sia] ?? a.sia), severity: a.severity }; + }); + return [...fromMock, ...fromAlarms].sort((x, y) => y.ts.localeCompare(x.ts)); + }, []); + + const filtered = rows.filter((r) => (sev === 'all' || r.severity === sev) && (!q || (r.callsign + r.text + r.type).toLowerCase().includes(q.toLowerCase()))); + + return ( +
+ + + + +
+
+ {SEV.map((s) => ( + + ))} +
+
+ + setQ(e.target.value)} placeholder="поиск…" className="w-48 rounded-lg border border-border bg-surface-2 py-1.5 pl-8 pr-2 text-sm text-fg outline-none focus:border-accent" /> +
+
+
+ + + + + + + + + + + + {filtered.map((r, i) => ( + + + + + + + + ))} + {!filtered.length && } + +
ВремяОбъектРазделТипСобытие
{dt(r.ts)}{r.callsign}{r.section}{r.type} +
+ + {r.text} +
+
Событий не найдено
+
+

Демо-журнал (моки + сработки ПЦН). В проде — единый EventBus per-арендатор с фильтрами и экспортом.

+
+
+ ); +} diff --git a/src/modules/gps/GpsModule.tsx b/src/modules/gps/GpsModule.tsx new file mode 100644 index 0000000..4af5a18 --- /dev/null +++ b/src/modules/gps/GpsModule.tsx @@ -0,0 +1,556 @@ +import { useEffect, useMemo, useState, type ComponentType, type MouseEvent } from 'react'; +import { + Navigation, + Satellite, + Fuel, + Gauge, + Wifi, + WifiOff, + Crosshair, + ChevronDown, + Check, + Route as RouteIcon, + FileDown, + Clock, + Pencil, + ShieldCheck, + Eye, + EyeOff, +} from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, CardHeader, Badge, StatusDot } from '@/components/ui/primitives'; +import { PropList } from '@/components/ui/Collapsible'; +import { cn, fmt } from '@/lib/utils'; +import { tripsOf, type Trip } from '@/data/trips'; +import { detectRoadEvents, ROAD_KIND } from '@/data/roadEvents'; +import { MapSwitch } from './MapSwitch'; +import { useGpsData } from './useGpsData'; +import { ContextMenu, ObjectModal } from './ObjectDetails'; +import { ObjectEditModal } from './ObjectEdit'; +import { TrackPlayer } from './TrackPlayer'; +import { ShareCoords } from './ShareCoords'; +import { GpsTabs } from './GpsTabs'; +import { useObjectCustom, pictogramFor } from './objectCustom'; +import { ExportPdfButton } from '@/components/ExportPdfButton'; +import { MapFrame } from '@/components/MapFrame'; +import { useAuth } from '@/core/store'; +import { can, SECTION_LABEL } from '@/core/auth'; +import type { MonitoredEntity } from '@/core/types'; + +const TYPE_LABEL: Record = { transport: 'Транспорт', energy: 'Щит/энерго', realty: 'Объект' }; +const TELE_LABEL: Record = { + speed: ['Скорость', 'км/ч'], + sat: ['Спутники', ''], + fuel: ['Топливо', '%'], + voltage: ['Бортсеть', 'В'], + pwr_ext: ['Сеть внешн.', 'В'], + pwr_int: ['АКБ', 'В'], + temp: ['Температура', '°C'], +}; + +function Metric({ icon: Icon, value, unit }: { icon: any; value: number; unit: string }) { + return ( + + + {fmt(value, 0)} + {unit} + + ); +} + +function FollowBox({ on, onClick }: { on: boolean; onClick: () => void }) { + return ( + + ); +} + +function ObjectRow({ + e, + icon: Icon, + open, + follow, + canEdit, + pcnVisible, + mapHidden, + address, + onToggleOpen, + onFollow, + onSelect, + onContext, + onEdit, + onTogglePcn, + onToggleVisible, +}: { + e: MonitoredEntity; + icon: ComponentType<{ className?: string }>; + open: boolean; + follow: boolean; + canEdit: boolean; + pcnVisible: boolean; + mapHidden: boolean; + address?: string; + onToggleOpen: () => void; + onFollow: () => void; + onSelect: () => void; + onContext: (ev: MouseEvent) => void; + onEdit: () => void; + onTogglePcn: () => void; + onToggleVisible: () => void; +}) { + const addr = address || (e.lat != null && e.lon != null ? `${e.lat.toFixed(4)}, ${e.lon.toFixed(4)}` : ''); + return ( +
+
+ + + + + {e.lat != null && e.lon != null && } +
+
+
+
+ {e.id}], + ['Клиент', e.clientId], + ['Тип', TYPE_LABEL[e.type]], + ['Статус', e.online ? 'на связи' : 'нет связи'], + ['Координаты', `${e.lat?.toFixed(4)}, ${e.lon?.toFixed(4)}`], + ...Object.entries(e.telemetry).map( + ([k, v]) => [TELE_LABEL[k]?.[0] ?? k, `${fmt(v, 0)} ${TELE_LABEL[k]?.[1] ?? ''}`.trim()] as [string, string], + ), + ]} + /> +
+ + {canEdit && ( + + )} + +
+
+
+
+
+ ); +} + +function downloadTripsCsv(name: string, list: Trip[]) { + const head = 'Начало;Конец;Откуда;Куда;Дистанция_км;Длительность_мин;Макс_кмч;Средн_кмч'; + const rows = list.map((t) => + [ + new Date(t.start).toLocaleString('ru-RU'), + new Date(t.end).toLocaleString('ru-RU'), + t.from, + t.to, + t.distanceKm, + t.durationMin, + t.maxSpeed, + t.avgSpeed, + ].join(';'), + ); + const csv = '' + [head, ...rows].join('\n'); + const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); + const a = document.createElement('a'); + a.href = url; + a.download = `Поездки_${name}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +export function GpsModule() { + const user = useAuth((s) => s.user); + const canEdit = can(user, 'gps', 'edit'); + const canReports = can(user, 'gps', 'reports'); + const { objs: rawObjs, tracks, live } = useGpsData(user); + const overrides = useObjectCustom((s) => s.overrides); + const setOverride = useObjectCustom((s) => s.set); + + // Применяем клиентские оверрайды: имя и ручные координаты (стационарные объекты). + const objs = useMemo( + () => + rawObjs.map((e) => { + const o = overrides[e.id]; + if (!o) return e; + return { + ...e, + name: o.name ?? e.name, + lat: o.lat ?? e.lat, + lon: o.lon ?? e.lon, + }; + }), + [rawObjs, overrides], + ); + const trackColors = useMemo( + () => Object.fromEntries(Object.entries(overrides).filter(([, o]) => o.trackColor).map(([id, o]) => [id, o.trackColor!])), + [overrides], + ); + + const [sel, setSel] = useState(''); + const [openId, setOpenId] = useState(null); + const [followId, setFollowId] = useState(null); + const [ctx, setCtx] = useState<{ x: number; y: number; id: string } | null>(null); + const [modal, setModal] = useState<{ id: string; tab: 'stats' | 'props' } | null>(null); + const [editId, setEditId] = useState(null); + const [tripId, setTripId] = useState(null); + const [cursor, setCursor] = useState<{ lat: number; lon: number; label?: string } | null>(null); + const [showEvents, setShowEvents] = useState(true); + + useEffect(() => { + if (objs.length && !objs.find((e) => e.id === sel)) setSel(objs[0].id); + }, [objs, sel]); + + const cur = objs.find((e) => e.id === sel) ?? objs[0]; + const curTrips = cur ? tripsOf(cur.id) : []; + const trip = curTrips.find((t) => t.id === tripId) ?? null; + const mapTracks = trip ? { ...tracks, [`trip-${trip.id}`]: trip.route } : tracks; + const mapTrackIds = [sel, followId, trip ? `trip-${trip.id}` : null].filter(Boolean) as string[]; + const roadEvents = useMemo(() => detectRoadEvents(trip ? [trip] : curTrips), [trip, curTrips]); + const mapEvents = showEvents + ? roadEvents.map((e) => ({ id: e.id, lat: e.lat, lon: e.lon, color: ROAD_KIND[e.kind].color, char: ROAD_KIND[e.kind].char, label: e.label, sub: e.sub })) + : []; + + if (!objs.length) { + return ( +
+ + Нет доступных объектов для вашей роли. +
+ ); + } + + return ( +
+ + {!canEdit && только просмотр} + {canReports && ( + ({ + title: 'GPS-мониторинг — объекты', + columns: ['Объект', 'Тип', 'Статус', 'Координаты', 'Скорость, км/ч'], + rows: objs.map((e) => [ + e.name, + TYPE_LABEL[e.type], + e.online ? 'на связи' : 'офлайн', + e.lat != null ? `${e.lat.toFixed(4)}, ${e.lon?.toFixed(4)}` : '—', + 'speed' in e.telemetry ? fmt(e.telemetry.speed, 0) : '—', + ]), + note: `Онлайн: ${objs.filter((e) => e.online).length} из ${objs.length}.`, + })} + /> + )} + + {objs.filter((e) => e.online).length}/{objs.length} онлайн + + {live ? 'live · шлюз' : 'демо'} +
+ } + /> + + + +
+ + +
+ {objs.map((e) => ( + setOverride(e.id, { mapHidden: !overrides[e.id]?.mapHidden })} + onToggleOpen={() => setOpenId((id) => (id === e.id ? null : e.id))} + onFollow={() => { + setFollowId((f) => (f === e.id ? null : e.id)); + setSel(e.id); + }} + onSelect={() => setSel(e.id)} + onContext={(ev) => { + ev.preventDefault(); + setSel(e.id); + setCtx({ x: ev.clientX, y: ev.clientY, id: e.id }); + }} + onEdit={() => setEditId(e.id)} + onTogglePcn={() => setOverride(e.id, { pcnVisible: !overrides[e.id]?.pcnVisible })} + /> + ))} +
+
+ + + + {!!overrides[cur.id]?.pcnVisible && ПЦН} + {followId === cur.id && слежение} + {cur.online ? 'на связи' : 'нет связи'} + {canEdit && ( + + )} +
+ } + /> +
+ + {(full) => ( + !overrides[e.id]?.mapHidden)} + selectedId={sel} + followId={followId} + onSelect={setSel} + tracks={mapTracks} + trackIds={mapTrackIds} + trackColors={trackColors} + cursor={cursor} + events={mapEvents} + className={full ? 'h-full w-full' : 'h-64 w-full sm:h-80 lg:h-[24rem]'} + /> + )} + +
+
+ {'speed' in cur.telemetry && } + {'sat' in cur.telemetry && } + {'fuel' in cur.telemetry && } + {'voltage' in cur.telemetry && } +
+ +
+ + {/* Плеер трека по выбранной поездке */} + {trip && ( +
+ { + setTripId(null); + setCursor(null); + }} + /> +
+ )} + + {/* Дорожные события на треке (детектор поездок/стоянок) */} + {roadEvents.length > 0 && ( + + setShowEvents((v) => !v)} + className={cn('inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium', showEvents ? 'bg-accent/15 text-accent' : 'border border-border text-fg-muted hover:bg-muted')} + > + {showEvents ? 'на карте ✓' : 'показать на карте'} + + } + /> +
+ {roadEvents.map((e) => { + const k = ROAD_KIND[e.kind]; + return ( + + {k.char} + {e.label} + {new Date(e.ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} + + ); + })} +
+
+ )} + + {/* Журнал перемещений (поездок) */} + + downloadTripsCsv(cur.name, curTrips)} + disabled={!curTrips.length} + className="inline-flex items-center gap-1.5 rounded-lg bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/25 disabled:opacity-40" + > + Отчёт CSV + + ) : ( + отчёты недоступны + ) + } + /> +
+ {curTrips.length === 0 ? ( +

Поездок нет

+ ) : ( + + + + + + + + + + + + {curTrips.map((t) => ( + setTripId(tripId === t.id ? null : t.id)} + className={cn('cursor-pointer text-fg hover:bg-muted/40', tripId === t.id && 'bg-accent/10')} + > + + + + + + + ))} + +
ВремяМаршрутДист.Время в путиМакс/средн
+
+ + {new Date(t.start).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}– + {new Date(t.end).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} +
+
+
+ + + {t.from} → {t.to} + +
+
{fmt(t.distanceKm)} км{t.durationMin} мин + {t.maxSpeed}/{t.avgSpeed} км/ч +
+ )} +
+
+ + {ctx && ( + setCtx(null)} + onStats={() => { + setModal({ id: ctx.id, tab: 'stats' }); + setCtx(null); + }} + onProps={() => { + setModal({ id: ctx.id, tab: 'props' }); + setCtx(null); + }} + onEdit={ + canEdit + ? () => { + setEditId(ctx.id); + setCtx(null); + } + : undefined + } + /> + )} + {editId && objs.find((e) => e.id === editId) && ( + e.id === editId)!} onClose={() => setEditId(null)} /> + )} + {modal && objs.find((e) => e.id === modal.id) && ( + e.id === modal.id)!} + tab={modal.tab} + onTab={(t) => setModal((m) => (m ? { ...m, tab: t } : m))} + onClose={() => setModal(null)} + /> + )} +
+ ); +} diff --git a/src/modules/gps/GpsTabs.tsx b/src/modules/gps/GpsTabs.tsx new file mode 100644 index 0000000..9a3daac --- /dev/null +++ b/src/modules/gps/GpsTabs.tsx @@ -0,0 +1,33 @@ +import { NavLink } from 'react-router-dom'; +import { Navigation, MapPinned, History } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Горизонтальное меню опций GPS-мониторинга: мониторинг · геозоны · история событий +const TABS = [ + { to: '/gps', label: 'GPS-мониторинг', icon: Navigation }, + { to: '/geofences', label: 'Геозоны', icon: MapPinned }, + { to: '/events', label: 'История событий', icon: History }, +]; + +export function GpsTabs() { + return ( +
+ {TABS.map((t) => ( + + cn( + 'inline-flex items-center gap-2 rounded-lg px-3.5 py-2 text-sm font-medium transition-colors', + isActive ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:bg-muted hover:text-fg', + ) + } + > + + {t.label} + + ))} +
+ ); +} diff --git a/src/modules/gps/MapSwitch.tsx b/src/modules/gps/MapSwitch.tsx new file mode 100644 index 0000000..7f706c7 --- /dev/null +++ b/src/modules/gps/MapSwitch.tsx @@ -0,0 +1,17 @@ +import { useEffect, useState, type ComponentProps } from 'react'; +import { MapView } from './MapView'; +import { MapViewGL } from './MapViewGL'; + +// «Вторая схема» карты: на мобильном (<1024px) — MapLibre GL (плавно, GPU), +// на десктопе — привычный Leaflet. Пропсы идентичны, переключение по ширине окна. +export function MapSwitch(props: ComponentProps) { + const [gl, setGl] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches); + useEffect(() => { + const mq = window.matchMedia('(max-width: 1023px)'); + const on = () => setGl(mq.matches); + on(); + mq.addEventListener('change', on); + return () => mq.removeEventListener('change', on); + }, []); + return gl ? : ; +} diff --git a/src/modules/gps/MapView.tsx b/src/modules/gps/MapView.tsx new file mode 100644 index 0000000..95eff55 --- /dev/null +++ b/src/modules/gps/MapView.tsx @@ -0,0 +1,270 @@ +import { useEffect, useRef } from 'react'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import type { MonitoredEntity } from '@/core/types'; +import { useTheme } from '@/core/theme'; + +// Базовые слои карт (переключатель в углу). 2ГИС и Яндекс — для РФ. +function baseLayers(): Record { + return { + 'Тёмная': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', { attribution: '© OSM · © CARTO', maxZoom: 19, subdomains: 'abcd' }), + 'Светлая': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', { attribution: '© OSM · © CARTO', maxZoom: 19, subdomains: 'abcd' }), + 'OSM': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }), + '2ГИС': L.tileLayer('https://tile{s}.maps.2gis.com/tiles?x={x}&y={y}&z={z}', { attribution: '© 2ГИС', maxZoom: 18, subdomains: '0123' }), + 'Яндекс': L.tileLayer('https://core-renderer-tiles.maps.yandex.net/tiles?l=map&x={x}&y={y}&z={z}&scale=1&lang=ru_RU', { attribution: '© Яндекс Карты', maxZoom: 19 }), + }; +} + +function color(e: MonitoredEntity, colors?: Record): string { + if (colors?.[e.id]) return colors[e.id]; + if (!e.online) return '#64748B'; + if (e.type === 'energy') return '#0EA5E9'; + if (e.type === 'network') return '#A78BFA'; + return '#22C55E'; +} + +function makeIcon(e: MonitoredEntity, follow: boolean, colors?: Record, glyphs?: Record) { + const c = color(e, colors); + const glyph = glyphs?.[e.id]; + if (glyph) { + // Маркер с пиктограммой (напр. ГБР ГЛОНАСС-авто на пульте ПЦН) + const ring = follow ? `box-shadow:0 0 0 4px ${c}66, 0 0 0 8px ${c}22;` : `box-shadow:0 0 0 3px ${c}40;`; + return L.divIcon({ + className: '', + html: `
${glyph}
`, + iconSize: [26, 26], + iconAnchor: [13, 13], + popupAnchor: [0, -14], + }); + } + const ring = follow ? `box-shadow:0 0 0 4px ${c}66, 0 0 0 8px ${c}22;` : e.online ? `box-shadow:0 0 0 4px ${c}33;` : ''; + return L.divIcon({ + className: '', + html: `
`, + iconSize: [14, 14], + iconAnchor: [7, 7], + popupAnchor: [0, -10], + }); +} + +export function MapView({ + entities, + selectedId, + followId, + onSelect, + className, + tracks, + trackIds, + trackColors, + links, + colors, + glyphs, + cursor, + circles, + events, +}: { + entities: MonitoredEntity[]; + selectedId: string; + followId: string | null; + onSelect: (id: string) => void; + className?: string; + tracks?: Record>; + trackIds?: string[]; + trackColors?: Record; + links?: Array<{ id: string; from: [number, number]; to: [number, number]; style: 'solid' | 'dash' | 'flow'; color: string }>; + colors?: Record; // переопределение цвета маркера по id объекта (ПЦН: тревога/охрана) + glyphs?: Record; // HTML-пиктограмма внутри маркера по id (ПЦН: ГБР ГЛОНАСС-авто) + cursor?: { lat: number; lon: number; label?: string } | null; // курсор плеера трека + circles?: Array<{ id: string; lat: number; lon: number; radius: number; color: string; name?: string }>; // геозоны + events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>; // дорожные события +}) { + const theme = useTheme((s) => s.theme); + const elRef = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef>({}); + const linesRef = useRef>({}); + const linksRef = useRef>({}); + const cursorRef = useRef(null); + const circlesRef = useRef>({}); + const eventsRef = useRef([]); + + useEffect(() => { + if (!elRef.current || mapRef.current) return; + const map = L.map(elRef.current, { center: [44.05, 43.06], zoom: 11, zoomControl: true }); + const bases = baseLayers(); + bases[theme === 'dark' ? 'Тёмная' : 'Светлая'].addTo(map); + L.control.layers(bases, undefined, { position: 'topright', collapsed: true }).addTo(map); + mapRef.current = map; + + const pts = entities.filter((e) => e.lat != null && e.lon != null); + pts.forEach((e) => { + const m = L.marker([e.lat!, e.lon!], { icon: makeIcon(e, false, colors, glyphs) }).addTo(map); + m.bindPopup(`
${e.name}
${e.online ? 'на связи' : 'нет связи'}
`, { maxWidth: 220 }); + m.on('click', () => onSelect(e.id)); + markersRef.current[e.id] = m; + }); + if (pts.length) map.fitBounds(L.latLngBounds(pts.map((e) => [e.lat!, e.lon!] as [number, number])).pad(0.4)); + const t = setTimeout(() => map.invalidateSize(), 200); + return () => { + clearTimeout(t); + map.remove(); + mapRef.current = null; + markersRef.current = {}; + linesRef.current = {}; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + entities.forEach((e) => { + const m = markersRef.current[e.id]; + if (m && e.lat != null && e.lon != null) { + m.setLatLng([e.lat, e.lon]); + m.setIcon(makeIcon(e, followId === e.id, colors, glyphs)); + } + }); + const map = mapRef.current; + if (map && followId) { + const f = entities.find((x) => x.id === followId); + if (f && f.lat != null && f.lon != null) map.panTo([f.lat, f.lon], { animate: true, duration: 0.5 }); + } + }, [entities, followId, colors, glyphs]); + + useEffect(() => { + const map = mapRef.current; + if (!map || !followId) return; + const f = entities.find((x) => x.id === followId); + if (f && f.lat != null && f.lon != null) map.setView([f.lat, f.lon], Math.max(map.getZoom(), 15), { animate: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [followId]); + + useEffect(() => { + const map = mapRef.current; + const e = entities.find((x) => x.id === selectedId); + if (!map || !e || e.lat == null || e.lon == null || followId === selectedId) return; + map.flyTo([e.lat, e.lon], 15, { duration: 0.6 }); + markersRef.current[e.id]?.openPopup(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const ids = (trackIds || []).filter(Boolean); + Object.keys(linesRef.current).forEach((id) => { + if (!ids.includes(id)) { + map.removeLayer(linesRef.current[id]); + delete linesRef.current[id]; + } + }); + ids.forEach((id) => { + const pts = (tracks || {})[id]; + if (!pts || pts.length < 2) return; + const baseId = id.startsWith('trip-') ? id.slice(5) : id; + const c = trackColors?.[id] ?? trackColors?.[baseId] ?? (followId === id ? '#22C55E' : '#0EA5E9'); + if (linesRef.current[id]) linesRef.current[id].setLatLngs(pts).setStyle({ color: c }); + else linesRef.current[id] = L.polyline(pts, { color: c, weight: 3, opacity: 0.85 }).addTo(map); + }); + }, [tracks, trackIds, followId, trackColors]); + + // Связи между узлами (визуализация связанности): сплошная / пунктир / анимированный пунктир + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const cur = links || []; + const ids = new Set(cur.map((l) => l.id)); + Object.keys(linksRef.current).forEach((id) => { + if (!ids.has(id)) { + map.removeLayer(linksRef.current[id]); + delete linksRef.current[id]; + } + }); + cur.forEach((l) => { + if (linksRef.current[l.id]) map.removeLayer(linksRef.current[l.id]); + linksRef.current[l.id] = L.polyline([l.from, l.to], { + color: l.color, + weight: l.style === 'solid' ? 3.5 : 2.5, + opacity: 0.9, + dashArray: l.style === 'solid' ? undefined : '8 10', + className: l.style === 'flow' ? 'link-flow' : '', + }).addTo(map); + }); + }, [links]); + + // Курсор плеера трека — отдельный маркер, движется по маршруту при воспроизведении + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (!cursor) { + if (cursorRef.current) { + map.removeLayer(cursorRef.current); + cursorRef.current = null; + } + return; + } + const icon = L.divIcon({ + className: '', + html: `
`, + iconSize: [18, 18], + iconAnchor: [9, 9], + }); + if (!cursorRef.current) { + cursorRef.current = L.marker([cursor.lat, cursor.lon], { icon, zIndexOffset: 1000 }).addTo(map); + } else { + cursorRef.current.setLatLng([cursor.lat, cursor.lon]); + } + if (cursor.label) cursorRef.current.bindTooltip(cursor.label, { permanent: false, direction: 'top' }); + }, [cursor]); + + // Геозоны (круги): рисуем/обновляем/удаляем + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const cur = circles || []; + const ids = new Set(cur.map((c) => c.id)); + Object.keys(circlesRef.current).forEach((id) => { + if (!ids.has(id)) { + map.removeLayer(circlesRef.current[id]); + delete circlesRef.current[id]; + } + }); + cur.forEach((c) => { + const existing = circlesRef.current[c.id]; + if (existing) { + existing.setLatLng([c.lat, c.lon]).setRadius(c.radius).setStyle({ color: c.color, fillColor: c.color }); + } else { + const circle = L.circle([c.lat, c.lon], { radius: c.radius, color: c.color, fillColor: c.color, fillOpacity: 0.12, weight: 2 }).addTo(map); + if (c.name) circle.bindTooltip(c.name, { permanent: false, direction: 'center' }); + circlesRef.current[c.id] = circle; + } + }); + }, [circles]); + + // Дорожные события на треке (старт/стоп/стоянка/превышение) + useEffect(() => { + const map = mapRef.current; + if (!map) return; + eventsRef.current.forEach((m) => map.removeLayer(m)); + eventsRef.current = []; + (events || []).forEach((e) => { + const icon = L.divIcon({ + className: '', + html: `
${e.char}
`, + iconSize: [18, 18], + iconAnchor: [9, 9], + popupAnchor: [0, -12], + }); + const m = L.marker([e.lat, e.lon], { icon, zIndexOffset: 500 }).addTo(map); + m.bindPopup(`
${e.label}
${e.sub ? `
${e.sub}
` : ''}`, { maxWidth: 220 }); + eventsRef.current.push(m); + }); + }, [events]); + + useEffect(() => { + const onResize = () => mapRef.current?.invalidateSize(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + return
; +} diff --git a/src/modules/gps/MapViewGL.tsx b/src/modules/gps/MapViewGL.tsx new file mode 100644 index 0000000..33d01bd --- /dev/null +++ b/src/modules/gps/MapViewGL.tsx @@ -0,0 +1,232 @@ +import { useEffect, useRef } from 'react'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { MonitoredEntity } from '@/core/types'; +import { useTheme } from '@/core/theme'; + +// «Вторая схема» карты — MapLibre GL (WebGL-вектор, плавно на мобильных). +// Базовая карта CARTO positron/dark-matter (без API-ключа). Треки/объекты/события — GPU-слои. +// Drop-in: те же пропсы, что у Leaflet MapView. Референс: benzrf/map.benzin-status.tech. + +type LngLat = [number, number]; // [lat, lon] на входе (как в Leaflet MapView) + +interface Props { + entities: MonitoredEntity[]; + selectedId: string; + followId: string | null; + onSelect: (id: string) => void; + className?: string; + tracks?: Record>; + trackIds?: string[]; + trackColors?: Record; + links?: Array<{ id: string; from: LngLat; to: LngLat; style: 'solid' | 'dash' | 'flow'; color: string }>; + colors?: Record; + glyphs?: Record; + cursor?: { lat: number; lon: number; label?: string } | null; + circles?: Array<{ id: string; lat: number; lon: number; radius: number; color: string; name?: string }>; + events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>; +} + +const STYLE = (dark: boolean) => + `https://basemaps.cartocdn.com/gl/${dark ? 'dark-matter' : 'positron'}-gl-style/style.json`; + +function objColor(e: MonitoredEntity, colors?: Record): string { + if (colors?.[e.id]) return colors[e.id]; + if (!e.online) return '#64748B'; + if (e.type === 'energy') return '#0EA5E9'; + if (e.type === 'network') return '#A78BFA'; + return '#22C55E'; +} + +function circlePolygon(lon: number, lat: number, radiusM: number, steps = 48): number[][] { + const R = 6378137; + const dLat = (radiusM / R) * (180 / Math.PI); + const dLon = dLat / Math.cos((lat * Math.PI) / 180); + const ring: number[][] = []; + for (let i = 0; i <= steps; i++) { + const a = (i / steps) * 2 * Math.PI; + ring.push([lon + dLon * Math.cos(a), lat + dLat * Math.sin(a)]); + } + return ring; +} + +const EMPTY = { type: 'FeatureCollection' as const, features: [] as any[] }; + +export function MapViewGL(props: Props) { + const theme = useTheme((s) => s.theme); + const elRef = useRef(null); + const mapRef = useRef(null); + const propsRef = useRef(props); + propsRef.current = props; + const readyRef = useRef(false); + const fittedRef = useRef(false); + const popupRef = useRef(null); + + // ---- построение GeoJSON из текущих пропсов ---- + function build() { + const p = propsRef.current; + const objects = { + type: 'FeatureCollection' as const, + features: p.entities + .filter((e) => e.lat != null && e.lon != null) + .map((e) => ({ + type: 'Feature' as const, + properties: { + id: e.id, name: e.name, online: e.online ? 1 : 0, + color: objColor(e, p.colors), follow: p.followId === e.id ? 1 : 0, + glyph: p.glyphs?.[e.id] ?? '', + }, + geometry: { type: 'Point' as const, coordinates: [e.lon!, e.lat!] }, + })), + }; + const tracks = { + type: 'FeatureCollection' as const, + features: (p.trackIds || []).filter(Boolean).flatMap((id) => { + const pts = (p.tracks || {})[id]; + if (!pts || pts.length < 2) return []; + const baseId = id.startsWith('trip-') ? id.slice(5) : id; + const c = p.trackColors?.[id] ?? p.trackColors?.[baseId] ?? (p.followId === id ? '#22C55E' : '#0EA5E9'); + return [{ + type: 'Feature' as const, properties: { id, color: c }, + geometry: { type: 'LineString' as const, coordinates: pts.map(([la, lo]) => [lo, la]) }, + }]; + }), + }; + const events = { + type: 'FeatureCollection' as const, + features: (p.events || []).map((e) => ({ + type: 'Feature' as const, properties: { color: e.color, char: e.char, label: e.label, sub: e.sub ?? '' }, + geometry: { type: 'Point' as const, coordinates: [e.lon, e.lat] }, + })), + }; + const cursor = p.cursor + ? { type: 'FeatureCollection' as const, features: [{ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: [p.cursor.lon, p.cursor.lat] } }] } + : EMPTY; + const geozones = { + type: 'FeatureCollection' as const, + features: (p.circles || []).map((c) => ({ + type: 'Feature' as const, properties: { color: c.color, name: c.name ?? '' }, + geometry: { type: 'Polygon' as const, coordinates: [circlePolygon(c.lon, c.lat, c.radius)] }, + })), + }; + const links = { + type: 'FeatureCollection' as const, + features: (p.links || []).map((l) => ({ + type: 'Feature' as const, properties: { color: l.color, dashed: l.style === 'solid' ? 0 : 1 }, + geometry: { type: 'LineString' as const, coordinates: [[l.from[1], l.from[0]], [l.to[1], l.to[0]]] }, + })), + }; + return { objects, tracks, events, cursor, geozones, links }; + } + + function sync() { + const map = mapRef.current; + if (!map || !readyRef.current) return; + const d = build(); + (map.getSource('objects') as maplibregl.GeoJSONSource)?.setData(d.objects as any); + (map.getSource('tracks') as maplibregl.GeoJSONSource)?.setData(d.tracks as any); + (map.getSource('events') as maplibregl.GeoJSONSource)?.setData(d.events as any); + (map.getSource('cursor') as maplibregl.GeoJSONSource)?.setData(d.cursor as any); + (map.getSource('geozones') as maplibregl.GeoJSONSource)?.setData(d.geozones as any); + (map.getSource('links') as maplibregl.GeoJSONSource)?.setData(d.links as any); + // первый fit по объектам + if (!fittedRef.current && d.objects.features.length) { + const b = new maplibregl.LngLatBounds(); + d.objects.features.forEach((f: any) => b.extend(f.geometry.coordinates)); + if (!b.isEmpty()) { map.fitBounds(b, { padding: 60, maxZoom: 15, duration: 0 }); fittedRef.current = true; } + } + } + + function addLayers() { + const map = mapRef.current!; + const src = (id: string) => { if (!map.getSource(id)) map.addSource(id, { type: 'geojson', data: EMPTY as any }); }; + ['geozones', 'links', 'tracks', 'events', 'objects', 'cursor'].forEach(src); + + if (!map.getLayer('geozones-fill')) map.addLayer({ id: 'geozones-fill', type: 'fill', source: 'geozones', paint: { 'fill-color': ['get', 'color'], 'fill-opacity': 0.12 } }); + if (!map.getLayer('geozones-line')) map.addLayer({ id: 'geozones-line', type: 'line', source: 'geozones', paint: { 'line-color': ['get', 'color'], 'line-width': 2 } }); + if (!map.getLayer('links-line')) map.addLayer({ id: 'links-line', type: 'line', source: 'links', paint: { 'line-color': ['get', 'color'], 'line-width': 2.5, 'line-opacity': 0.9, 'line-dasharray': [2, 2] } }); + if (!map.getLayer('tracks-line')) map.addLayer({ id: 'tracks-line', type: 'line', source: 'tracks', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 2.5, 15, 4.5], 'line-opacity': 0.9 } }); + if (!map.getLayer('objects-circle')) map.addLayer({ + id: 'objects-circle', type: 'circle', source: 'objects', + paint: { + 'circle-radius': ['case', ['==', ['get', 'follow'], 1], 9, 6], + 'circle-color': ['get', 'color'], + 'circle-stroke-color': '#0F172A', 'circle-stroke-width': 2, + 'circle-opacity': ['case', ['==', ['get', 'online'], 1], 1, 0.65], + }, + }); + if (!map.getLayer('events-circle')) map.addLayer({ id: 'events-circle', type: 'circle', source: 'events', paint: { 'circle-radius': 9, 'circle-color': ['get', 'color'], 'circle-stroke-color': '#0F172A', 'circle-stroke-width': 2 } }); + if (!map.getLayer('events-char')) map.addLayer({ id: 'events-char', type: 'symbol', source: 'events', layout: { 'text-field': ['get', 'char'], 'text-size': 11, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } }); + if (!map.getLayer('cursor-circle')) map.addLayer({ id: 'cursor-circle', type: 'circle', source: 'cursor', paint: { 'circle-radius': 8, 'circle-color': '#22C55E', 'circle-stroke-color': '#fff', 'circle-stroke-width': 3 } }); + } + + // ---- инициализация карты (один раз) ---- + useEffect(() => { + if (!elRef.current || mapRef.current) return; + const map = new maplibregl.Map({ + container: elRef.current, + style: STYLE(theme === 'dark'), + center: [43.06, 44.05], zoom: 10, attributionControl: { compact: true }, + }); + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); + mapRef.current = map; + + const onStyle = () => { addLayers(); readyRef.current = true; sync(); }; + map.on('load', onStyle); + map.on('styledata', () => { if (!map.getLayer('objects-circle')) onStyle(); }); + + // клик по объекту → выбор + попап + map.on('click', 'objects-circle', (e) => { + const f = e.features?.[0]; if (!f) return; + const pr = f.properties as any; + propsRef.current.onSelect(pr.id); + popupRef.current?.remove(); + popupRef.current = new maplibregl.Popup({ offset: 12, closeButton: false }) + .setLngLat((f.geometry as any).coordinates) + .setHTML(`
${pr.name}
${pr.online ? 'на связи' : 'нет связи'}
`) + .addTo(map); + }); + map.on('click', 'events-circle', (e) => { + const f = e.features?.[0]; if (!f) return; const pr = f.properties as any; + new maplibregl.Popup({ offset: 12, closeButton: false }).setLngLat((f.geometry as any).coordinates) + .setHTML(`
${pr.label}
${pr.sub ? `
${pr.sub}
` : ''}`).addTo(map); + }); + ['objects-circle', 'events-circle'].forEach((l) => { + map.on('mouseenter', l, () => { map.getCanvas().style.cursor = 'pointer'; }); + map.on('mouseleave', l, () => { map.getCanvas().style.cursor = ''; }); + }); + + return () => { map.remove(); mapRef.current = null; readyRef.current = false; fittedRef.current = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // данные меняются → sync + useEffect(() => { sync(); }, [props.entities, props.tracks, props.trackIds, props.trackColors, props.events, props.cursor, props.circles, props.links, props.colors, props.followId]); + + // follow → плавно за объектом + useEffect(() => { + const map = mapRef.current; if (!map || !props.followId) return; + const f = props.entities.find((x) => x.id === props.followId); + if (f && f.lat != null && f.lon != null) map.easeTo({ center: [f.lon, f.lat], zoom: Math.max(map.getZoom(), 14), duration: 500 }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.followId]); + + // select → flyTo + попап + useEffect(() => { + const map = mapRef.current; if (!map) return; + const e = props.entities.find((x) => x.id === props.selectedId); + if (!e || e.lat == null || e.lon == null || props.followId === props.selectedId) return; + map.flyTo({ center: [e.lon, e.lat], zoom: 15, duration: 600 }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.selectedId]); + + // смена темы → новый стиль, слои переустанавливаются на style.load + useEffect(() => { + const map = mapRef.current; if (!map || !readyRef.current) return; + readyRef.current = false; + map.setStyle(STYLE(theme === 'dark')); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [theme]); + + return
; +} diff --git a/src/modules/gps/ObjectDetails.tsx b/src/modules/gps/ObjectDetails.tsx new file mode 100644 index 0000000..90a34dc --- /dev/null +++ b/src/modules/gps/ObjectDetails.tsx @@ -0,0 +1,169 @@ +import { BarChart3, ListTree, X, Route as RouteIcon, Gauge, Clock, MapPin, Pencil } from 'lucide-react'; +import { Badge } from '@/components/ui/primitives'; +import { PropList } from '@/components/ui/Collapsible'; +import { cn, fmt } from '@/lib/utils'; +import { tripsOf } from '@/data/trips'; +import type { MonitoredEntity } from '@/core/types'; + +const TYPE_LABEL: Record = { transport: 'Транспорт', energy: 'Щит/энерго', realty: 'Объект', network: 'Сетевой узел' }; + +// Контекстное меню объекта (правый клик / «при нажатии») +export function ContextMenu({ + x, + y, + onStats, + onProps, + onEdit, + onClose, +}: { + x: number; + y: number; + onStats: () => void; + onProps: () => void; + onEdit?: () => void; + onClose: () => void; +}) { + return ( + <> +
{ e.preventDefault(); onClose(); }} /> +
+ + + {onEdit && ( + + )} +
+ + ); +} + +function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function StatsView({ e }: { e: MonitoredEntity }) { + const trips = tripsOf(e.id); + const dist = trips.reduce((s, t) => s + t.distanceKm, 0); + const dur = trips.reduce((s, t) => s + t.durationMin, 0); + const maxV = trips.length ? Math.max(...trips.map((t) => t.maxSpeed)) : 0; + const avgV = trips.length ? Math.round(trips.reduce((s, t) => s + t.avgSpeed, 0) / trips.length) : 0; + return ( +
+
+ + + + + + +
+ {trips.length > 0 && ( +
+
Последние перемещения
+
+ {trips.map((t) => ( +
+ + + {new Date(t.start).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} + + + + {t.from} → {t.to} + + {fmt(t.distanceKm)} км +
+ ))} +
+
+ )} +
+ ); +} + +function PropsView({ e }: { e: MonitoredEntity }) { + return ( + {e.id}], + ['Клиент', e.clientId], + ['Тип', TYPE_LABEL[e.type] ?? e.type], + ['Статус', e.online ? 'на связи' : 'нет связи'], + ['Координаты', e.lat != null ? `${e.lat.toFixed(4)}, ${e.lon?.toFixed(4)}` : '—'], + ...Object.entries(e.telemetry).map(([k, v]) => [k, `${fmt(v, 0)}`] as [string, string]), + ...Object.entries(e.meta ?? {}).map(([k, v]) => [k, v] as [string, string]), + ['Обновлено', new Date(e.updatedAt).toLocaleString('ru-RU')], + ]} + /> + ); +} + +// Модалка с двумя вкладками: статистика / свойства +export function ObjectModal({ + entity, + tab, + onTab, + onClose, +}: { + entity: MonitoredEntity; + tab: 'stats' | 'props'; + onTab: (t: 'stats' | 'props') => void; + onClose: () => void; +}) { + return ( +
+
+
+
+
+ +
+
{entity.name}
+
{TYPE_LABEL[entity.type] ?? entity.type}
+
+
+ +
+
+ {(['stats', 'props'] as const).map((t) => ( + + ))} +
+
{tab === 'stats' ? : }
+
+ + {entity.online ? 'на связи' : 'офлайн'} + + {entity.clientId} +
+
+
+ ); +} diff --git a/src/modules/gps/ObjectEdit.tsx b/src/modules/gps/ObjectEdit.tsx new file mode 100644 index 0000000..708a886 --- /dev/null +++ b/src/modules/gps/ObjectEdit.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import { X, Check, RotateCcw, Palette, Pencil, MapPin } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { MonitoredEntity } from '@/core/types'; +import { useObjectCustom, PICTOGRAMS, TRACK_PALETTE, pictogramFor } from './objectCustom'; + +// Редактирование объекта: имя, пиктограмма, цвет трека. Изменения — клиентские +// (поверх данных шлюза), сохраняются в localStorage, применяются сразу. +export function ObjectEditModal({ entity, onClose }: { entity: MonitoredEntity; onClose: () => void }) { + const ov = useObjectCustom((s) => s.overrides[entity.id]) || {}; + const setOv = useObjectCustom((s) => s.set); + const resetOv = useObjectCustom((s) => s.reset); + + const [name, setName] = useState(ov.name ?? entity.name); + const [icon, setIcon] = useState(ov.icon); + const [color, setColor] = useState(ov.trackColor ?? TRACK_PALETTE[0]); + const [lat, setLat] = useState(String(ov.lat ?? entity.lat ?? '')); + const [lon, setLon] = useState(String(ov.lon ?? entity.lon ?? '')); + + const save = () => { + const latN = parseFloat(lat.replace(',', '.')); + const lonN = parseFloat(lon.replace(',', '.')); + setOv(entity.id, { + name: name.trim() || entity.name, + icon, + trackColor: color, + lat: Number.isFinite(latN) ? latN : undefined, + lon: Number.isFinite(lonN) ? lonN : undefined, + }); + onClose(); + }; + const reset = () => { + resetOv(entity.id); + onClose(); + }; + + return ( +
+
+
+
+
+ +
Редактирование объекта
+
+ +
+ +
+ {/* Имя */} +
+ + setName(e.target.value)} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent" + /> +

ID объекта {entity.id} · переименование локальное (отображение)

+
+ + {/* Пиктограмма */} +
+ +
+ + {PICTOGRAMS.map((p) => { + const I = p.icon; + return ( + + ); + })} +
+
+ + {/* Координаты */} +
+ +
+ setLat(e.target.value)} + inputMode="decimal" + placeholder="44.0500" + className="tnum w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent" + /> + setLon(e.target.value)} + inputMode="decimal" + placeholder="43.0600" + className="tnum w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent" + /> +
+

ручная установка позиции (стационарные объекты/щиты) · маркер переедет на карте. Пусто — позиция от трекера.

+
+ + {/* Цвет трека */} +
+ +
+ {TRACK_PALETTE.map((c) => ( +
+
+ + {/* Предпросмотр */} +
+ {(() => { + const I = pictogramFor(entity.type, icon); + return ; + })()} + {name || entity.name} + +
+
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/modules/gps/ShareCoords.tsx b/src/modules/gps/ShareCoords.tsx new file mode 100644 index 0000000..8823c55 --- /dev/null +++ b/src/modules/gps/ShareCoords.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { Share2, Send, MessageCircle, MessageSquare, Copy, Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Отправка координат объекта в мессенджер: выбор Telegram / WhatsApp / МАКС (копирование). +export function ShareCoords({ name, address, lat, lon }: { name: string; address?: string; lat: number; lon: number }) { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const mapUrl = `https://yandex.ru/maps/?pt=${lon}%2C${lat}&z=17&l=map`; + const text = `📍 ${name}${address ? ` · ${address}` : ''}\nКоординаты: ${lat.toFixed(5)}, ${lon.toFixed(5)}\nКарта: ${mapUrl}`; + + const tg = () => window.open(`https://t.me/share/url?url=${encodeURIComponent(mapUrl)}&text=${encodeURIComponent(text)}`, '_blank', 'noopener'); + const wa = () => window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, '_blank', 'noopener'); + const copy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable */ + } + }; + + return ( +
+ + {open && ( + <> +
{ e.stopPropagation(); setOpen(false); }} /> +
e.stopPropagation()}> +
координаты в…
+ + + + +
+ + )} +
+ ); +} diff --git a/src/modules/gps/TrackPlayer.tsx b/src/modules/gps/TrackPlayer.tsx new file mode 100644 index 0000000..c512edc --- /dev/null +++ b/src/modules/gps/TrackPlayer.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Play, Pause, SkipBack, Gauge, Clock, Route as RouteIcon, X, MapPin } from 'lucide-react'; +import { cn, fmt } from '@/lib/utils'; +import type { Trip } from '@/data/trips'; + +type Pt = [number, number]; + +// Гаверсинус (км) — для длины сегментов маршрута +function hav(a: Pt, b: Pt): number { + const R = 6371; + const dLat = ((b[0] - a[0]) * Math.PI) / 180; + const dLon = ((b[1] - a[1]) * Math.PI) / 180; + const la1 = (a[0] * Math.PI) / 180; + const la2 = (b[0] * Math.PI) / 180; + const x = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(x))); +} + +// Разреженный маршрут → равномерные точки по дистанции (плавное воспроизведение) +function densify(route: Pt[], n: number): Pt[] { + if (route.length < 2) return route.slice(); + const segs: number[] = []; + let total = 0; + for (let i = 1; i < route.length; i++) { + const d = hav(route[i - 1], route[i]); + segs.push(d); + total += d; + } + const out: Pt[] = []; + for (let k = 0; k <= n; k++) { + let target = (k / n) * total; + let i = 0; + while (i < segs.length && target > segs[i]) { + target -= segs[i]; + i++; + } + if (i >= segs.length) { + out.push(route[route.length - 1]); + continue; + } + const t = segs[i] > 0 ? target / segs[i] : 0; + const a = route[i]; + const b = route[i + 1]; + out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + return out; +} + +const N = 80; +const tm = (iso: string) => new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + +export function TrackPlayer({ + trip, + color = '#22C55E', + onCursor, + onClose, +}: { + trip: Trip; + color?: string; + onCursor: (c: { lat: number; lon: number; label?: string } | null) => void; + onClose: () => void; +}) { + const pts = useMemo(() => densify(trip.route, N), [trip]); + const [idx, setIdx] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(1); + const timer = useRef | null>(null); + + const startMs = new Date(trip.start).getTime(); + const endMs = new Date(trip.end).getTime(); + const progress = idx / N; + const curTime = new Date(startMs + (endMs - startMs) * progress).toISOString(); + const doneKm = trip.distanceKm * progress; + // Косметическая кривая скорости (колокол между средней и максимальной) + const curSpeed = Math.round(trip.avgSpeed + (trip.maxSpeed - trip.avgSpeed) * Math.sin(progress * Math.PI)); + + // Тик воспроизведения: весь трек ~10с при 1×, быстрее при 2×/4× + useEffect(() => { + if (!playing) return; + timer.current = setInterval(() => { + setIdx((i) => { + if (i >= N) { + setPlaying(false); + return i; + } + return i + 1; + }); + }, 10000 / N / speed); + return () => { + if (timer.current) clearInterval(timer.current); + }; + }, [playing, speed]); + + // Передаём курсор родителю (маркер на карте) + useEffect(() => { + const p = pts[Math.min(idx, pts.length - 1)]; + if (p) onCursor({ lat: p[0], lon: p[1], label: tm(curTime) }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idx, pts]); + + useEffect(() => () => onCursor(null), [onCursor]); + + const reset = () => { + setIdx(0); + setPlaying(true); + }; + + return ( +
+
+
+ +
+
Плеер трека
+
{trip.from} → {trip.to}
+
+
+ +
+ +
+ + + { + setIdx(Number(e.target.value)); + setPlaying(false); + }} + className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-accent" + style={{ accentColor: color }} + /> +
+ {[1, 2, 4].map((s) => ( + + ))} +
+
+ +
+ + + + +
+
+ ); +} + +function Readout({ icon: Icon, label, value }: { icon: any; label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} diff --git a/src/modules/gps/objectCustom.ts b/src/modules/gps/objectCustom.ts new file mode 100644 index 0000000..d10a016 --- /dev/null +++ b/src/modules/gps/objectCustom.ts @@ -0,0 +1,89 @@ +import { create } from 'zustand'; +import type { ComponentType } from 'react'; +import { Truck, Tractor, Car, Bus, Package, Building2, Server, Droplet, Zap, MapPin, Navigation, Ship } from 'lucide-react'; + +// Пользовательская кастомизация объекта (клиентская, поверх данных шлюза): +// переименование, пиктограмма, цвет трека. Персист в localStorage. + +export interface ObjectOverride { + name?: string; + icon?: string; // ключ из PICTOGRAMS + trackColor?: string; // HEX + lat?: number; // ручная коррекция координат (стационарные объекты/щиты) + lon?: number; + pcnVisible?: boolean; // ГБР ГЛОНАСС-трекер — показывать на карте охранного пульта (ПЦН) + mapHidden?: boolean; // скрыть объект с карты GPS-мониторинга (в списке остаётся) + address?: string; // адрес объекта (малый шрифт рядом с именем) +} + +export const PICTOGRAMS: Array<{ key: string; label: string; icon: ComponentType<{ className?: string }> }> = [ + { key: 'truck', label: 'Грузовик', icon: Truck }, + { key: 'car', label: 'Легковой', icon: Car }, + { key: 'bus', label: 'Автобус', icon: Bus }, + { key: 'tractor', label: 'Трактор', icon: Tractor }, + { key: 'ship', label: 'Судно', icon: Ship }, + { key: 'cabinet', label: 'Щит/энерго', icon: Zap }, + { key: 'server', label: 'Узел/сервер', icon: Server }, + { key: 'building', label: 'Объект/здание', icon: Building2 }, + { key: 'pump', label: 'Насос/вода', icon: Droplet }, + { key: 'box', label: 'Груз/контейнер', icon: Package }, + { key: 'pin', label: 'Метка', icon: MapPin }, +]; + +const PICT_MAP: Record> = Object.fromEntries(PICTOGRAMS.map((p) => [p.key, p.icon])); + +// Иконка по умолчанию для типа объекта (если пользователь не выбрал свою) +const DEFAULT_BY_TYPE: Record> = { + transport: Truck, + energy: Zap, + realty: Building2, + network: Server, +}; + +export function pictogramFor(type: string, iconKey?: string): ComponentType<{ className?: string }> { + if (iconKey && PICT_MAP[iconKey]) return PICT_MAP[iconKey]; + return DEFAULT_BY_TYPE[type] ?? Navigation; +} + +// Палитра цветов трека (быстрый выбор + произвольный через input type=color) +export const TRACK_PALETTE = ['#22C55E', '#0EA5E9', '#F59E0B', '#EF4444', '#A78BFA', '#EC4899', '#14B8A6', '#FFFFFF']; + +const KEY = 'sm-obj-overrides'; +function load(): Record { + try { + return JSON.parse(localStorage.getItem(KEY) || '{}'); + } catch { + return {}; + } +} + +interface CustomState { + overrides: Record; + set: (id: string, patch: ObjectOverride) => void; + reset: (id: string) => void; +} + +export const useObjectCustom = create((set) => ({ + overrides: load(), + set: (id, patch) => + set((s) => { + const next = { ...s.overrides, [id]: { ...s.overrides[id], ...patch } }; + try { + localStorage.setItem(KEY, JSON.stringify(next)); + } catch { + /* quota */ + } + return { overrides: next }; + }), + reset: (id) => + set((s) => { + const next = { ...s.overrides }; + delete next[id]; + try { + localStorage.setItem(KEY, JSON.stringify(next)); + } catch { + /* quota */ + } + return { overrides: next }; + }), +})); diff --git a/src/modules/gps/useGpsData.ts b/src/modules/gps/useGpsData.ts new file mode 100644 index 0000000..46ff0a9 --- /dev/null +++ b/src/modules/gps/useGpsData.ts @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { apiGet, openSocket } from '@/api/client'; +import { entities as DEMO } from '@/data/mock'; +import { isObjectAllowed, type User } from '@/core/auth'; +import type { MonitoredEntity } from '@/core/types'; + +export type Tracks = Record>; + +// Живые данные GPS из шлюза (REST снапшот + WS позиции → треки). +// Фолбэк: встроенное демо с локальной симуляцией движения. +export function useGpsData(user: User): { objs: MonitoredEntity[]; tracks: Tracks; live: boolean } { + const [objs, setObjs] = useState([]); + const [tracks, setTracks] = useState({}); + const [live, setLive] = useState(false); + + useEffect(() => { + let stop = false; + let ws: WebSocket | undefined; + let sim: ReturnType | undefined; + + const pushTrack = (id: string, lat: number, lon: number) => + setTracks((t) => ({ ...t, [id]: [...(t[id] || []).slice(-400), [lat, lon]] })); + + const startFallback = () => { + const demo = DEMO.filter((e) => isObjectAllowed(user, 'gps', e.id)).map((e) => ({ ...e })); + setObjs(demo); + setLive(false); + const tr: Tracks = {}; + demo.forEach((e) => e.lat != null && (tr[e.id] = [[e.lat, e.lon!]])); + setTracks(tr); + const hd: Record = {}; + demo.forEach((e) => (hd[e.id] = Math.random() * 6)); + sim = setInterval(() => { + setObjs((cs) => + cs.map((e) => { + if (e.type !== 'transport' || !e.online || e.lat == null) return e; + hd[e.id] += (Math.random() - 0.5) * 0.6; + const sp = Math.round(35 + Math.random() * 45); + const st = (sp / 3600) * 0.02; + const lat = +(e.lat + Math.cos(hd[e.id]) * st).toFixed(5); + const lon = +(e.lon! + Math.sin(hd[e.id]) * st).toFixed(5); + pushTrack(e.id, lat, lon); + return { ...e, lat, lon, telemetry: { ...e.telemetry, speed: sp }, updatedAt: new Date().toISOString() }; + }), + ); + }, 3000); + }; + + (async () => { + try { + const ents = await apiGet('/entities'); + if (stop) return; + setObjs(ents); + setLive(true); + ws = openSocket((msg) => { + if (msg.type === 'position') { + const { id, lat, lon } = msg.data; + setObjs((cs) => cs.map((e) => (e.id === id ? { ...e, lat, lon, updatedAt: msg.data.ts } : e))); + pushTrack(id, lat, lon); + } else if (msg.type === 'entity') { + setObjs((cs) => { + const i = cs.findIndex((x) => x.id === msg.data.id); + if (i < 0) return [...cs, msg.data]; + const n = [...cs]; + n[i] = msg.data; + return n; + }); + } + }); + } catch { + if (!stop) startFallback(); + } + })(); + + return () => { + stop = true; + ws?.close(); + if (sim) clearInterval(sim); + }; + }, [user]); + + return { objs, tracks, live }; +} diff --git a/src/modules/network/NetworkModule.tsx b/src/modules/network/NetworkModule.tsx new file mode 100644 index 0000000..58f7a50 --- /dev/null +++ b/src/modules/network/NetworkModule.tsx @@ -0,0 +1,181 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Network, Wifi, WifiOff, Gauge, Users, Activity, Share2, Camera } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, CardHeader, Badge, StatusDot } from '@/components/ui/primitives'; +import { Collapsible, PropList } from '@/components/ui/Collapsible'; +import { ExportPdfButton } from '@/components/ExportPdfButton'; +import { MapFrame } from '@/components/MapFrame'; +import { cn, fmt } from '@/lib/utils'; +import { meshNodes } from '@/data/mesh2'; +import { MapView } from '@/modules/gps/MapView'; +import { useAuth } from '@/core/store'; +import { can, isObjectAllowed, SECTION_LABEL } from '@/core/auth'; +import type { MonitoredEntity } from '@/core/types'; + +const HUB_ID = 'mesh2-hub'; + +// Состояние связи узла → стиль линии и цвет +function linkStyle(n: MonitoredEntity): { style: 'solid' | 'dash' | 'flow'; color: string } { + if (!n.online) return { style: 'solid', color: '#EF4444' }; // разрыв + if (n.telemetry.latency > 5) return { style: 'dash', color: '#F59E0B' }; // деградация + return { style: 'flow', color: '#22C55E' }; // ок (поток) +} + +function photoOf(n: MonitoredEntity) { + const camera = /камера/i.test(n.name); + const kind = camera ? 'Снимок камеры' : 'Фото прибора'; + const svg = ` + + + + + + ${kind} + ${n.name} + 06:00 · сегодня + `; + return { url: `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`, kind }; +} + +export function NetworkModule() { + const user = useAuth((s) => s.user); + const nodes = useMemo(() => meshNodes.filter((n) => isObjectAllowed(user, 'netinfra', n.id)), [user]); + const canReports = can(user, 'netinfra', 'reports'); + const hub = meshNodes.find((n) => n.id === HUB_ID); + + const [sel, setSel] = useState(nodes[0]?.id ?? ''); + const [linkOn, setLinkOn] = useState>(new Set()); + useEffect(() => { + setSel(nodes[0]?.id ?? ''); + }, [nodes]); + const cur = nodes.find((n) => n.id === sel) ?? nodes[0]; + + const links = useMemo(() => { + if (!hub || hub.lat == null) return []; + return nodes + .filter((n) => linkOn.has(n.id) && n.id !== HUB_ID && n.lat != null) + .map((n) => ({ id: `lnk-${n.id}`, from: [n.lat!, n.lon!] as [number, number], to: [hub.lat!, hub.lon!] as [number, number], ...linkStyle(n) })); + }, [nodes, linkOn, hub]); + + if (!nodes.length) { + return ( +
+ + Нет доступных узлов для вашей роли. +
+ ); + } + + return ( +
+ + + {nodes.filter((n) => n.online).length}/{nodes.length} онлайн + + {canReports && ( + ({ + title: 'Сетевая инфраструктура mesh2 — узлы', + columns: ['Узел', 'IP', 'Роль', 'Статус', 'Задержка, мс', 'Аплинк, Мбит'], + rows: nodes.map((n) => [n.name, n.meta?.ip ?? '', n.meta?.role ?? '', n.online ? 'онлайн' : 'офлайн', n.online ? fmt(n.telemetry.latency) : '—', n.online ? fmt(n.telemetry.uplink, 0) : '—']), + })} + /> + )} +
+ } + /> + +
+ + +
+ {nodes.map((n) => { + const on = linkOn.has(n.id); + const ls = linkStyle(n); + return ( +
+ + o && setSel(n.id)} + left={} + title={n.name} + subtitle={n.meta?.role} + right={n.online ? {fmt(n.telemetry.latency)} мс : } + > + {n.meta?.ip}], + ['Модель', n.meta?.model ?? '—'], + ['Роль', n.meta?.role ?? '—'], + ['Аплинк', n.meta?.uplink ?? '—'], + ['Задержка', n.online ? `${fmt(n.telemetry.latency)} мс` : '—'], + ['Канал', n.online ? `${fmt(n.telemetry.uplink, 0)} Мбит/с` : '—'], + ['Клиентов', n.online ? `${fmt(n.telemetry.clients, 0)}` : '—'], + ['Статус', n.online ? 'онлайн' : 'нет связи'], + ]} + /> +
+
+ {photoOf(n).kind} · обновление 3×/сутки +
+ {photoOf(n).kind} +
+
+
+ ); + })} +
+
+ + + {cur.online ? 'онлайн' : 'нет связи'}} /> +
+ + {(full) => ( + + )} + +
+
+ {cur.online && ( + <> + {fmt(cur.telemetry.latency)} мс + {fmt(cur.telemetry.uplink, 0)} Мбит/с + {fmt(cur.telemetry.clients, 0)} клиентов + {cur.meta?.uplink} + + )} +
+
+
+
+ ); +} diff --git a/src/modules/notify/NotifyModule.tsx b/src/modules/notify/NotifyModule.tsx new file mode 100644 index 0000000..a090bf7 --- /dev/null +++ b/src/modules/notify/NotifyModule.tsx @@ -0,0 +1,208 @@ +import { useState, type ReactNode } from 'react'; +import { BellRing, Plus, Pencil, Trash2, X, Check, Clock, ArrowUpRight, Layers } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { cn } from '@/lib/utils'; +import { SECTIONS, SECTION_LABEL, type SectionId } from '@/core/auth'; +import type { Severity } from '@/core/types'; +import { + useNotify, emptyRule, CHANNELS, CHANNEL_LABEL, SEVERITIES, SCHEDULE_LABEL, + type NotifyRule, type NotifySchedule, +} from '@/data/notify'; +import { NotifyTester } from './NotifyTester'; + +const SEV_SEV: Record = { critical: 'critical', warning: 'warning', info: 'info', ok: 'info' } as any; + +function Chip({ on, onClick, children }: { on: boolean; onClick: () => void; children: ReactNode }) { + return ( + + ); +} + +function RuleEditor({ rule, onClose }: { rule: NotifyRule; onClose: () => void }) { + const upsert = useNotify((s) => s.upsert); + const [r, setR] = useState(rule); + const toggleIn = (key: 'sections' | 'severities' | 'channels', v: string) => + setR((p) => { + const arr = p[key] as string[]; + return { ...p, [key]: arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v] } as NotifyRule; + }); + + return ( +
+
+
+
+
Правило уведомления
+ +
+ +
+
+ + setR({ ...r, name: e.target.value })} className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent" /> +
+ +
+
Разделы (триггер) · пусто = любой
+
+ {SECTIONS.map((s) => ( + toggleIn('sections', s)}>{SECTION_LABEL[s as SectionId]} + ))} +
+
+ +
+
Важность · пусто = любая
+
+ {SEVERITIES.map((s) => ( + toggleIn('severities', s.key)}>{s.label} + ))} +
+
+ +
+
Каналы (действия)
+
+ {CHANNELS.map((c) => { + const I = c.icon; + return ( + toggleIn('channels', c.key)}> + {c.label} + + ); + })} +
+
+ +
+ + setR({ ...r, recipients: e.target.value })} placeholder="+7 962 …, @username, mail@…, ГБР-1" className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted focus:border-accent" /> +
+ +
+
+ + setR({ ...r, escalateAfterMin: Math.max(0, Number(e.target.value)) })} className="tnum w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent" /> +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ ); +} + +export function NotifyModule() { + const { rules, remove, toggle } = useNotify(); + const [edit, setEdit] = useState(null); + + return ( +
+ + локально · демо + +
+ } + /> + + + +
+ {rules.map((r) => ( + +
+
+
+ + {r.name} + = 4 ? 'critical' : r.priority >= 3 ? 'warning' : 'info'}>P{r.priority} +
+ +
+ + {(r.sections.length ? r.sections : ['любой раздел']).map((s) => ( + {SECTION_LABEL[s as SectionId] ?? s} + ))} + {(r.severities.length ? r.severities : ['любая важность']).map((s) => ( + {s} + ))} +
+ +
+ {r.channels.map((k) => { + const c = CHANNELS.find((x) => x.key === k); + if (!c) return null; + const I = c.icon; + return ( + + {c.label} + + ); + })} +
+ +
+ {r.recipients && адресаты: {r.recipients}} + {SCHEDULE_LABEL[r.schedule]} + {r.escalateAfterMin > 0 && эскалация через {r.escalateAfterMin} мин} +
+
+ +
+ + +
+
+
+ ))} + {!rules.length && Правил нет. Добавьте первое — «Правило».} +
+ +
+ Правило срабатывает, когда событие попадает под раздел И важность (пустой список = «любой»). Действия выполняются по каналам; при отсутствии реакции в течение «эскалации» — повтор/следующий адресат. Сейчас правила хранятся локально (демо); в проде выполняются на шлюзе (EventBus) per-арендатор, с реальной доставкой в мессенджеры (Telegram/WhatsApp/МАКС), звонком и вызовом ГБР. +
+ + {edit && setEdit(null)} />} +
+ ); +} diff --git a/src/modules/notify/NotifyTester.tsx b/src/modules/notify/NotifyTester.tsx new file mode 100644 index 0000000..76b911b --- /dev/null +++ b/src/modules/notify/NotifyTester.tsx @@ -0,0 +1,121 @@ +import { useMemo, useState } from 'react'; +import { FlaskConical, Zap, Play, ChevronDown, XCircle, CheckCircle2, Send } from 'lucide-react'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { cn } from '@/lib/utils'; +import { SECTIONS, SECTION_LABEL, type SectionId } from '@/core/auth'; +import type { Severity } from '@/core/types'; +import { useNotify, CHANNELS, SEVERITIES } from '@/data/notify'; +import { evaluate, type NEvent } from '@/core/notifyEngine'; + +const digits = (p: string) => p.replace(/\D/g, ''); +const openTg = (t: string) => window.open(`https://t.me/share/url?url=${encodeURIComponent('https://monitor.servaki.online')}&text=${encodeURIComponent(t)}`, '_blank', 'noopener'); +const openWa = (phone: string, t: string) => window.open(`https://wa.me/${digits(phone)}?text=${encodeURIComponent(t)}`, '_blank', 'noopener'); + +const tm = (iso: string) => new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + +interface Fired { ts: string; ev: NEvent; channels: string[]; rules: string[] } + +// Симулятор/дебаггер правил: событие → какие правила сработали, ПОЧЕМУ, и какие каналы. +export function NotifyTester() { + const rules = useNotify((s) => s.rules); + const [section, setSection] = useState('security'); + const [severity, setSeverity] = useState('critical'); + const [obj, setObj] = useState('Магазин «ЭлектроКМВ»'); + const [showMiss, setShowMiss] = useState(false); + const [log, setLog] = useState([]); + + const ev: NEvent = { section, severity, objectName: obj, text: 'тестовое событие' }; + const res = useMemo(() => evaluate(ev, rules), [section, severity, obj, rules]); + + const chLabel = (k: string) => CHANNELS.find((c) => c.key === k)?.label ?? k; + + const fire = () => { + const text = `⚠️ ${severity.toUpperCase()} · ${SECTION_LABEL[section]} · ${obj || '—'}. Событие обработано правилами «Орбиты»: ${res.matched.map((m) => m.rule.name).join(', ') || 'нет'}. Каналы: ${res.channels.map(chLabel).join(', ')}.`; + // реально выполняем мессенджер-каналы (демо) + if (res.channels.includes('telegram') || res.channels.includes('max')) openTg(text); + if (res.channels.includes('whatsapp')) { + const phone = res.matched.map((m) => m.rule.recipients).join(' ').match(/\+?\d[\d\s()-]{9,}/)?.[0]; + if (phone) openWa(phone, text); + } + setLog((l) => [{ ts: new Date().toISOString(), ev: { ...ev }, channels: res.channels, rules: res.matched.map((m) => m.rule.name) }, ...l].slice(0, 12)); + }; + + return ( + + dry-run + выполнение} + /> +
+
+ + + + +
+ + {/* Результат */} +
+
+ + + {res.matched.length ? `Сработало правил: ${res.matched.length}` : 'Ни одно правило не сработало'} + + {res.channels.map((k) => ( + {chLabel(k)} + ))} +
+ {res.matched.map((m) => ( +
+ + {m.rule.name} — {m.reasons.join(' · ')} → {m.rule.channels.map(chLabel).join(', ')} +
+ ))} + {!!res.missed.length && ( + + )} + {showMiss && res.missed.map((m) => ( +
+ {m.rule.name} — {m.why} +
+ ))} +
+ + {/* Журнал срабатываний */} + {!!log.length && ( +
+
Журнал срабатываний
+
+ {log.map((f, i) => ( +
+ + {tm(f.ts)} + {SECTION_LABEL[f.ev.section as SectionId]} · {f.ev.severity} · {f.ev.objectName} + → {f.channels.map(chLabel).join(', ')} +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/src/modules/repos/ReposModule.tsx b/src/modules/repos/ReposModule.tsx new file mode 100644 index 0000000..fefaf56 --- /dev/null +++ b/src/modules/repos/ReposModule.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import { FolderGit2, GitBranch, Lock, Globe, ExternalLink, Sparkles, CircleSlash } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { KpiCard } from '@/components/ui/KpiCard'; +import { cn } from '@/lib/utils'; +import { + repos, + repoUrl, + CATEGORY_LABEL, + CATEGORY_ORDER, + GITEA_BASE, + type GiteaRepo, +} from '@/data/repos'; + +function RepoRow({ r }: { r: GiteaRepo }) { + return ( + + +
+
+ {r.name} + {r.isNew && ( + + новый + + )} + {r.empty && ( + + пусто + + )} +
+
{r.note}
+
+ + + {r.branch} + + {r.private ? ( + + ) : ( + + )} + +
+ ); +} + +export function ReposModule() { + const stats = useMemo(() => { + const total = repos.length; + const pub = repos.filter((r) => !r.private).length; + const master = repos.filter((r) => r.branch === 'master').length; + const empty = repos.filter((r) => r.empty).length; + return { total, priv: total - pub, pub, master, empty }; + }, []); + + const byCat = useMemo( + () => CATEGORY_ORDER.map((cat) => ({ cat, items: repos.filter((r) => r.category === cat) })), + [], + ); + + return ( +
+ + Открыть Gitea + + } + /> + +
+ + + + +
+ +
+ {byCat.map(({ cat, items }) => ( + + {items.length}} /> +
+ {items.map((r) => ( + + ))} +
+
+ ))} +
+ +

+ Источник истины — git1. Карта сверена вручную 2026-06-30 ({stats.total} репо, {stats.empty} пустой). Пуш — через + http.extraHeader, токен в URL не сохраняется. +

+
+ ); +} diff --git a/src/modules/security/AlarmHandling.tsx b/src/modules/security/AlarmHandling.tsx new file mode 100644 index 0000000..99dc452 --- /dev/null +++ b/src/modules/security/AlarmHandling.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react'; +import { Clock, CheckCircle2, Timer, MessageSquarePlus, Send } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAlarmHandling, STAGES, PRESET_COMMENTS } from '@/data/alarmHandling'; + +function fmtDur(ms: number): string { + if (ms < 0) ms = 0; + const s = Math.floor(ms / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = s % 60; + return (h ? `${h}ч ` : '') + `${m}м ${String(ss).padStart(2, '0')}с`; +} +const tm = (iso: string) => new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + +// Карточка отработки сработки: таймер, стадии (выезд/прибытие/…), журнал комментариев +export function AlarmHandling({ alarmId, startIso, author, canEdit }: { alarmId: string; startIso: string; author: string; canEdit: boolean }) { + const h = useAlarmHandling((s) => s.map[alarmId]); + const stamp = useAlarmHandling((s) => s.stamp); + const addComment = useAlarmHandling((s) => s.addComment); + const stages = h?.stages || {}; + const comments = h?.comments || []; + + const [now, setNow] = useState(() => Date.now()); + const [txt, setTxt] = useState(''); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + + const startMs = new Date(startIso).getTime(); + const closed = !!stages.closed; + const endMs = closed ? new Date(stages.closed).getTime() : now; + const elapsed = endMs - startMs; + const taken = !!stages.taken; + + const doStamp = (k: string) => stamp(alarmId, k, new Date().toISOString()); + const doComment = (text: string) => { + if (!text.trim()) return; + addComment(alarmId, text.trim(), author, new Date().toISOString()); + setTxt(''); + }; + + return ( +
+ {/* Таймер + статус «на контроле» */} +
+ + + {closed ? 'отработана' : taken ? 'В РАБОТЕ · на контроле' : 'НЕ ВЗЯТА'} · {fmtDur(elapsed)} + + {stages.enroute && !stages.onsite && в пути: {fmtDur(now - new Date(stages.enroute).getTime())}} +
+ + {/* Стадии-этапы с таймстампами */} +
+ {STAGES.map((st) => { + const ts = stages[st.key]; + const done = !!ts; + return ( + + ); + })} +
+ + {/* Журнал комментариев + типовые */} + {canEdit && ( +
+
+ {PRESET_COMMENTS.map((c) => ( + + ))} +
+
+ setTxt(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && doComment(txt)} + placeholder="комментарий оператора…" + className="flex-1 rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 text-xs text-fg outline-none placeholder:text-fg-muted focus:border-accent" + /> + +
+
+ )} + + {/* Лента отработки */} + {!!comments.length && ( +
+ {comments.map((c, i) => ( +
+ + {tm(c.ts)} + {c.text} + {c.author} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/modules/security/ArchiveModule.tsx b/src/modules/security/ArchiveModule.tsx new file mode 100644 index 0000000..2dc4b19 --- /dev/null +++ b/src/modules/security/ArchiveModule.tsx @@ -0,0 +1,124 @@ +import { useMemo, useState } from 'react'; +import { Archive, Siren, CheckCircle2, XCircle, Clock, Camera, ChevronDown, Activity } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { KpiCard } from '@/components/ui/KpiCard'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { cn } from '@/lib/utils'; +import { SecurityTabs } from './SecurityTabs'; +import { HourStrip, hourCounts } from './HourStrip'; +import { alarmFeed, guardObjects, SIA_LABEL, type AlarmStatus } from '@/data/security'; +import { useAlarmHandling, STAGES } from '@/data/alarmHandling'; + +const STATUS_LABEL: Record = { new: 'новая', taken: 'в работе', gbr: 'ГБР', closed: 'отработана' }; +const STATUS_SEV: Record = { new: 'critical', taken: 'warning', gbr: 'info', closed: 'ok' }; +const dt = (iso: string) => new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +const tm = (iso: string) => new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); +const DOT = { critical: 'bg-crit', warning: 'bg-warn', info: 'bg-info', ok: 'bg-ok' } as Record; + +export function ArchiveModule() { + const handling = useAlarmHandling((s) => s.map); + const [open, setOpen] = useState(null); + const [sev, setSev] = useState<'all' | 'critical' | 'warning' | 'info'>('all'); + const [hour, setHour] = useState(null); + + const total = alarmFeed.length; + const crit = alarmFeed.filter((a) => a.severity === 'critical').length; + const closed = alarmFeed.filter((a) => a.status === 'closed').length; + + const heatH = useMemo(() => hourCounts(alarmFeed), []); + + const list = alarmFeed.filter((a) => (sev === 'all' || a.severity === sev) && (hour == null || new Date(a.ts).getHours() === hour)); + + return ( +
+ + + +
+ + + + +
+ + {/* Интерактивная полоса по часам суток — клик по часу фильтрует список */} + +
+ Распределение по часам суток + {hour != null && ( + + )} +
+ +

Кликните столбец — список отфильтруется по часу.

+
+ + + + {(['all', 'critical', 'warning', 'info'] as const).map((s) => ( + + ))} +
+ } /> +
+ {list.map((a) => { + const o = guardObjects.find((g) => g.id === a.objId); + const h = handling[a.id]; + const isOpen = open === a.id; + return ( +
+ + {isOpen && ( +
+ {/* Реплей отработки: стадии + комментарии */} +
+
Отработка · стадии
+
+ {STAGES.map((st) => { + const ts = h?.stages?.[st.key]; + return ( +
+ {ts ? : } + {st.label} + {ts && {tm(ts)}} +
+ ); + })} +
+ {!!h?.comments?.length && ( +
+ {h.comments.map((c, i) => ( +
{tm(c.ts)}{c.text}{c.author}
+ ))} +
+ )} + {!h &&
Отработка не зафиксирована (демо).
} +
+ {/* Фотофиксация */} +
+
Фотофиксация
+ {o?.photoUrl ? ( + {`Снимок + ) : ( +
нет снимка
+ )} +
{SIA_LABEL[a.sia] ?? a.sia}{a.zone ? ` · зона ${a.zone}` : ''} · {o?.address}
+
+
+ )} +
+ ); + })} +
+ +
+ ); +} diff --git a/src/modules/security/GuardObjectEditor.tsx b/src/modules/security/GuardObjectEditor.tsx new file mode 100644 index 0000000..9d7cc14 --- /dev/null +++ b/src/modules/security/GuardObjectEditor.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { X, Check, Plus, Trash2, Building2 } from 'lucide-react'; +import { useGuardObjects } from '@/data/guardObjects'; +import type { GuardObject, ObjState } from '@/data/security'; + +const STATES: ObjState[] = ['armed', 'disarmed', 'alarm', 'offline']; +const STATE_LABEL: Record = { armed: 'под охраной', disarmed: 'снят', alarm: 'тревога', offline: 'нет связи' }; +const ZONE_KINDS = ['burglar', 'fire', 'panic', 'perimeter', 'tech'] as const; + +export function GuardObjectEditor({ obj, onClose }: { obj: GuardObject; onClose: () => void }) { + const upsert = useGuardObjects((s) => s.upsert); + const [o, setO] = useState(obj); + const set = (patch: Partial) => setO((p) => ({ ...p, ...patch })); + + const inp = 'w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent'; + const lab = 'mb-1 block text-[11px] font-semibold uppercase tracking-wide text-fg-muted'; + + return ( +
+
+
+
+
Охраняемый объект
+ +
+ +
+
+
set({ name: e.target.value })} className={inp} />
+
set({ account: e.target.value })} className={inp} />
+
+
set({ address: e.target.value })} className={inp} />
+
+
set({ lat: parseFloat(e.target.value.replace(',', '.')) || 0 })} inputMode="decimal" className={`${inp} tnum`} />
+
set({ lon: parseFloat(e.target.value.replace(',', '.')) || 0 })} inputMode="decimal" className={`${inp} tnum`} />
+
+
+
+
+
set({ channel: e.target.value })} className={inp} />
+
set({ cameraUrl: e.target.value })} placeholder="https://cam…/snapshot" className={inp} />
+
+ + {/* Список дозвона */} +
+
Список дозвона + +
+
+ {o.contacts.map((c, i) => ( +
+ set({ contacts: o.contacts.map((x, j) => j === i ? { ...x, name: e.target.value } : x) })} placeholder="ФИО" className={`${inp} flex-1`} /> + set({ contacts: o.contacts.map((x, j) => j === i ? { ...x, role: e.target.value } : x) })} placeholder="роль" className={`${inp} w-32`} /> + set({ contacts: o.contacts.map((x, j) => j === i ? { ...x, phone: e.target.value } : x) })} placeholder="+7…" className={`${inp} w-36`} /> + +
+ ))} + {!o.contacts.length &&

Контактов нет — добавьте.

} +
+
+ + {/* Зоны */} +
+
Зоны объекта + +
+
+ {o.zones.map((z, i) => ( +
+ set({ zones: o.zones.map((x, j) => j === i ? { ...x, n: Number(e.target.value) || 0 } : x) })} type="number" className={`${inp} tnum w-16`} /> + set({ zones: o.zones.map((x, j) => j === i ? { ...x, name: e.target.value } : x) })} placeholder="название зоны" className={`${inp} flex-1`} /> + + +
+ ))} + {!o.zones.length &&

Зон нет — добавьте.

} +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/modules/security/HourStrip.tsx b/src/modules/security/HourStrip.tsx new file mode 100644 index 0000000..e02036f --- /dev/null +++ b/src/modules/security/HourStrip.tsx @@ -0,0 +1,53 @@ +import { cn } from '@/lib/utils'; + +// Интерактивная полоса активности по часам суток (24 ячейки). +// Клик по часу — фильтр (если задан onHour). compact — мини-вариант для карточек. +export function HourStrip({ + counts, + active, + onHour, + compact, + labels, +}: { + counts: number[]; + active?: number | null; + onHour?: (h: number | null) => void; + compact?: boolean; + labels?: boolean; +}) { + const max = Math.max(1, ...counts); + const maxH = compact ? 22 : 44; + const minH = compact ? 3 : 8; + return ( +
+
+ {counts.map((c, i) => { + const dim = active != null ? (active === i ? 1 : 0.22) : c ? 0.35 + (c / max) * 0.6 : 0.12; + return ( +
+ {labels && ( +
+ 0006121823 +
+ )} +
+ ); +} + +// Счётчики по часам из массива событий с полем ts (ISO) +export function hourCounts(items: T[]): number[] { + const h = new Array(24).fill(0); + items.forEach((x) => { h[new Date(x.ts).getHours()]++; }); + return h; +} diff --git a/src/modules/security/ObjectsModule.tsx b/src/modules/security/ObjectsModule.tsx new file mode 100644 index 0000000..b2129f0 --- /dev/null +++ b/src/modules/security/ObjectsModule.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Building2, Siren, ShieldCheck, ShieldOff, WifiOff, Phone, ExternalLink, MapPin, Plus, Pencil, Trash2 } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { SecurityTabs } from './SecurityTabs'; +import { HourStrip, hourCounts } from './HourStrip'; +import { GuardObjectEditor } from './GuardObjectEditor'; +import { alarmFeed, type ObjState, type GuardObject } from '@/data/security'; +import { useGuardObjects, emptyObject } from '@/data/guardObjects'; +import { scopedTo } from '@/data/locations'; +import { useAuth } from '@/core/store'; +import { can } from '@/core/auth'; + +const LABEL: Record = { alarm: 'ТРЕВОГА', armed: 'под охраной', disarmed: 'снят', offline: 'нет связи' }; +const SEV: Record = { alarm: 'critical', armed: 'ok', disarmed: 'info', offline: 'warning' }; +const ICON = { alarm: Siren, armed: ShieldCheck, disarmed: ShieldOff, offline: WifiOff }; + +export function ObjectsModule() { + const navigate = useNavigate(); + const user = useAuth((s) => s.user); + const objects = scopedTo(user, useGuardObjects((s) => s.objects)); + const remove = useGuardObjects((s) => s.remove); + const canEdit = can(user, 'security', 'edit'); + const [edit, setEdit] = useState(null); + const newObject = () => setEdit({ ...emptyObject(), clientId: user.clientId || 'tempelhoff' }); + return ( +
+ + {objects.length} объектов + {canEdit && ( + + )} +
+ } + /> + +
+ {objects.map((o) => { + const I = ICON[o.state]; + const mapUrl = `https://yandex.ru/maps/?pt=${o.lon}%2C${o.lat}&z=17&l=map`; + const objHours = hourCounts(alarmFeed.filter((a) => a.objId === o.id)); + return ( + + navigate('/security')} className="text-left text-accent hover:underline" title="Открыть на пульте"> + {o.name} + + } + desc={ + + {o.address} + + } + right={ + o.state === 'alarm' ? ( + + ) : ( + {LABEL[o.state]} + ) + } + /> +
+
+ Объект ПЦН№ {o.account} + Протокол{o.proto} + Канал{o.channel} + Координаты{o.lat.toFixed(3)}, {o.lon.toFixed(3)} +
+ {/* Мини-таймлайн активности по часам */} +
+
Активность по часам
+ +
+
+ дозвон: {o.contacts.length} + зон: {o.zones.length} +
+
+ {o.contacts.slice(0, 2).map((c, i) => ( + + {c.name} + + ))} + {o.cameraUrl && ( + + камера + + )} +
+ {canEdit && ( +
+ + +
+ )} +
+
+ ); + })} +
+ + {edit && setEdit(null)} />} +
+ ); +} diff --git a/src/modules/security/SecurityModule.tsx b/src/modules/security/SecurityModule.tsx new file mode 100644 index 0000000..796f1cb --- /dev/null +++ b/src/modules/security/SecurityModule.tsx @@ -0,0 +1,384 @@ +import { useMemo, useState } from 'react'; +import { + ShieldAlert, Siren, ShieldCheck, ShieldOff, WifiOff, Truck, Phone, PhoneCall, + Camera, ExternalLink, MapPin, Clock, CheckCircle2, Radio, Hand, Send, MessageCircle, Car, +} from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { MapFrame } from '@/components/MapFrame'; +import { MapView } from '@/modules/gps/MapView'; +import { useGpsData } from '@/modules/gps/useGpsData'; +import { useObjectCustom } from '@/modules/gps/objectCustom'; +import { AlarmHandling } from './AlarmHandling'; +import { SecurityTabs } from './SecurityTabs'; +import { useGuardObjects } from '@/data/guardObjects'; +import { scopedTo } from '@/data/locations'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { KpiCard } from '@/components/ui/KpiCard'; +import { Collapsible } from '@/components/ui/Collapsible'; +import { cn } from '@/lib/utils'; +import { useAuth } from '@/core/store'; +import { can } from '@/core/auth'; +import type { MonitoredEntity } from '@/core/types'; +import { + alarmFeed, brigades as brigadeSeed, SIA_LABEL, SIA_TO_CID, + type AlarmEvent, type AlarmStatus, type ObjState, type GuardObject, +} from '@/data/security'; + +// Реконструкция «сырого» кадра приёмника (как пришло с прибора) — для поля исходных данных +function rawFrame(o: GuardObject, a: AlarmEvent): string { + const z = String(a.zone ?? 0).padStart(3, '0'); + if (o.proto === 'Surgard') { + return `[Surgard MLR2-DG] 5011 18 ${o.account} 18 ${a.cid} 01 ${z} ; ContactID ${a.cid}, зона ${z}, ${tm(a.ts)}`; + } + return `[SIA-DC09] "SIA-DCS"0042L0#${o.account}[#${o.account}|Nri${z}/${a.sia}${z}] ; CRC=OK, ${tm(a.ts)}`; +} + +const STATE_COLOR: Record = { + alarm: '#EF4444', + armed: '#22C55E', + disarmed: '#94A3B8', + offline: '#F59E0B', +}; +const STATE_LABEL: Record = { + alarm: 'ТРЕВОГА', + armed: 'под охраной', + disarmed: 'снят с охраны', + offline: 'нет связи', +}; +const STATE_SEV: Record = { + alarm: 'critical', armed: 'ok', disarmed: 'info', offline: 'warning', +}; +const STATUS_LABEL: Record = { + new: 'новая', taken: 'в работе', gbr: 'ГБР выехал', closed: 'отработана', +}; +const STATUS_SEV: Record = { + new: 'critical', taken: 'warning', gbr: 'info', closed: 'ok', +}; + +const tm = (iso: string) => new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + +// Пиктограмма ГБР ГЛОНАСС-авто на карте ПЦН (белый контур внутри синего маркера) +const CAR_GLYPH = + ''; + +const digits = (p: string) => p.replace(/\D/g, ''); +const openWa = (phone: string, text: string) => window.open(`https://wa.me/${digits(phone)}?text=${encodeURIComponent(text)}`, '_blank', 'noopener'); +const openTg = (text: string) => window.open(`https://t.me/share/url?url=${encodeURIComponent('https://monitor.servaki.online/security')}&text=${encodeURIComponent(text)}`, '_blank', 'noopener'); + +export function SecurityModule() { + const user = useAuth((s) => s.user); + const canEdit = can(user, 'security', 'edit'); + const { objs: gpsObjs } = useGpsData(user); + const overrides = useObjectCustom((s) => s.overrides); + + const [objects, setObjects] = useState(() => scopedTo(user, useGuardObjects.getState().objects)); + const [alarms, setAlarms] = useState(alarmFeed); + const [brigades] = useState(brigadeSeed); + const [selId, setSelId] = useState('obj-101'); + const [showPhoto, setShowPhoto] = useState(false); + const [called, setCalled] = useState>(new Set()); + + const sel = objects.find((o) => o.id === selId) ?? objects[0]; + + const armed = objects.filter((o) => o.state === 'armed').length; + const inAlarm = objects.filter((o) => o.state === 'alarm').length; + const gbrActive = alarms.filter((a) => a.status === 'gbr').length; + const offline = objects.filter((o) => o.state === 'offline').length; + + // Карта: объекты как сущности, цвет маркера = состояние охраны + const entities: MonitoredEntity[] = useMemo( + () => objects.map((o) => ({ + id: o.id, clientId: o.clientId, name: o.name, type: 'realty' as const, + online: o.state !== 'offline', telemetry: {}, lat: o.lat, lon: o.lon, + updatedAt: new Date(0).toISOString(), + })), + [objects], + ); + const colors = useMemo(() => Object.fromEntries(objects.map((o) => [o.id, STATE_COLOR[o.state]])), [objects]); + + // ГБР ГЛОНАСС-машины: объекты GPS-мониторинга с отметкой «Видим для ПЦН». + // Отдельная пиктограмма (авто) + синий цвет — визуально отличны от охраняемых объектов. + const gbrVehicles = useMemo( + () => gpsObjs.filter((o) => overrides[o.id]?.pcnVisible && o.lat != null && o.lon != null), + [gpsObjs, overrides], + ); + const mapEntities = useMemo( + () => [...entities, ...gbrVehicles.map((v) => ({ ...v, name: overrides[v.id]?.name ?? v.name }))], + [entities, gbrVehicles, overrides], + ); + const mapColors = useMemo( + () => ({ ...colors, ...Object.fromEntries(gbrVehicles.map((v) => [v.id, '#3B82F6'])) }), + [colors, gbrVehicles], + ); + const mapGlyphs = useMemo( + () => Object.fromEntries(gbrVehicles.map((v) => [v.id, CAR_GLYPH])), + [gbrVehicles], + ); + const mapKey = useMemo(() => 'pcn-' + gbrVehicles.map((v) => v.id).join(','), [gbrVehicles]); + + const setAlarmStatus = (id: string, status: AlarmStatus) => + setAlarms((prev) => prev.map((a) => (a.id === id ? { ...a, status } : a))); + + const closeObjAlarm = (objId: string, id: string) => { + setAlarms((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'closed' as AlarmStatus } : a))); + // если у объекта не осталось открытых тревог — вернуть под охрану + const stillOpen = alarms.some((a) => a.objId === objId && a.id !== id && a.status !== 'closed'); + if (!stillOpen) setObjects((prev) => prev.map((o) => (o.id === objId && o.state === 'alarm' ? { ...o, state: 'armed' } : o))); + }; + + const selAlarms = alarms.filter((a) => a.objId === sel.id); + const callKey = (i: number) => `${sel.id}:${i}`; + + const openSel = selAlarms.filter((a) => a.status !== 'closed'); + const dispatchText = + `🚨 ТРЕВОГА · ${sel.name} · ${sel.address} (объект ПЦН №${sel.account}). ` + + (openSel.map((a) => `${SIA_LABEL[a.sia] ?? a.sia}${a.zone ? ` зона ${a.zone}` : ''}`).join('; ') || 'событие') + + `. Координаты: ${sel.lat.toFixed(5)},${sel.lon.toFixed(5)}. Карта: https://monitor.servaki.online/security`; + + return ( +
+ источник: демо · ингрис Surgard/SIA — по плану} + /> + + + +
+ + + + + +
+ +
+ {/* Лента тревог */} + + live} /> +
+ {alarms.map((a) => { + const o = objects.find((x) => x.id === a.objId); + const active = a.status === 'new' || a.status === 'taken' || a.status === 'gbr'; + return ( + + ); + })} + {!alarms.length &&
Тревог нет
} +
+
+ + {/* Карта + карточка локации */} +
+ + {(full) => ( + { if (objects.find((o) => o.id === id)) { setSelId(id); setShowPhoto(false); } }} + colors={mapColors} + glyphs={mapGlyphs} + className={cn('w-full', full ? 'h-full' : 'h-[340px]')} + /> + )} + + {!!gbrVehicles.length && ( +
+ ГБР ГЛОНАСС-авто на карте: {gbrVehicles.length} + · отмечаются чекбоксом «Видим для ПЦН» в GPS-мониторинге +
+ )} + + {/* Карточка локации */} + + {sel.state === 'alarm' ? : sel.state === 'armed' ? : sel.state === 'offline' ? : }{STATE_LABEL[sel.state]}} + /> + + {/* Передать тревогу: на устройство/в мессенджер · ГБР · ответственному */} +
+ Передать тревогу + + + + формирует сообщение с объектом, событием и координатами +
+ +
+ {/* Список дозвона */} +
+
Список дозвона
+ + + {/* Вызов ГБР */} +
+
Группа реагирования
+
+ {brigades.map((b) => ( +
+ +
+
{b.name}
+
{b.crew} · {b.status === 'free' ? 'свободна' : b.status === 'enroute' ? 'в пути' : 'на объекте'}
+
+ + вызвать + +
+ ))} +
+
+
+ + {/* Фото с объекта + зоны + тех.карта */} +
+
+ Фото с объекта +
+ + {sel.cameraUrl && ( + + камера + + )} +
+
+ {showPhoto ? ( + {`Снимок + ) : ( +
+ Снимок по запросу · обновление по тревоге +
+ )} + +
+ Объект ПЦН№ {sel.account} + Протокол{sel.proto} + Канал{sel.channel} + Координаты{sel.lat.toFixed(3)}, {sel.lon.toFixed(3)} +
+ +
+ +
+ {sel.zones.map((z) => ( +
+ {z.n} + {z.name} + {z.kind} +
+ ))} +
+
+
+
+
+ + {/* События по объекту */} + {!!selAlarms.length && ( +
+
События объекта
+
+ {selAlarms.map((a) => ( +
+ + {tm(a.ts)} + {a.sia} + {SIA_LABEL[a.sia] ?? a.text}{a.zone ? ` · зона ${a.zone}` : ''} + {STATUS_LABEL[a.status]} +
+ ))} +
+ {/* Отработка активных сработок: стадии-таймеры + журнал */} + {openSel.map((a) => ( + + ))} + +

Surgard CID соответствие: {selAlarms.map((a) => `${a.sia}=${SIA_TO_CID[a.sia] ?? a.cid}`).join(' · ')}

+ + {/* Исходные данные сообщений — раскрывающееся поле «как пришло с прибора» */} +
+ +
+                      {selAlarms.map((a) => rawFrame(sel, a)).join('\n')}
+                    
+
+
+
+ )} +
+
+
+
+ ); +} diff --git a/src/modules/security/SecurityTabs.tsx b/src/modules/security/SecurityTabs.tsx new file mode 100644 index 0000000..8ded384 --- /dev/null +++ b/src/modules/security/SecurityTabs.tsx @@ -0,0 +1,33 @@ +import { NavLink } from 'react-router-dom'; +import { Siren, Building2, Archive } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Горизонтальные подпункты раздела «Охранный пульт (ПЦН)» +const TABS = [ + { to: '/security', label: 'Пульт', icon: Siren }, + { to: '/security-objects', label: 'Объекты', icon: Building2 }, + { to: '/security-archive', label: 'Архив сработок', icon: Archive }, +]; + +export function SecurityTabs() { + return ( +
+ {TABS.map((t) => ( + + cn( + 'inline-flex items-center gap-2 rounded-lg px-3.5 py-2 text-sm font-medium transition-colors', + isActive ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:bg-muted hover:text-fg', + ) + } + > + + {t.label} + + ))} +
+ ); +} diff --git a/src/modules/smart/LocationCard.tsx b/src/modules/smart/LocationCard.tsx new file mode 100644 index 0000000..23c69dd --- /dev/null +++ b/src/modules/smart/LocationCard.tsx @@ -0,0 +1,59 @@ +import { MapPin, Lightbulb, Power, Thermometer, DoorClosed, Check } from 'lucide-react'; +import { Card, CardHeader, Badge } from '@/components/ui/primitives'; +import { cn } from '@/lib/utils'; +import { useLocations, type Location, type SmartChannel } from '@/data/locations'; + +const KIND_ICON = { relay: Power, dimmer: Lightbulb, sensor: DoorClosed, climate: Thermometer }; + +function ChannelRow({ locId, ch }: { locId: string; ch: SmartChannel }) { + const toggle = useLocations((s) => s.toggleChannel); + const Icon = KIND_ICON[ch.kind]; + const controllable = ch.kind === 'relay' || ch.kind === 'dimmer'; + return ( +
+ + {ch.name} + {ch.value && {ch.value}} + {controllable ? ( + + ) : ( + {ch.on ? 'норма' : '—'} + )} +
+ ); +} + +export function LocationCard({ loc, showMainToggle }: { loc: Location; showMainToggle?: boolean }) { + const toggleMain = useLocations((s) => s.toggleMain); + const active = loc.channels.filter((c) => c.on).length; + return ( + + {loc.name}} + desc={loc.address} + right={{active}/{loc.channels.length} активно} + /> +
+ {loc.channels.map((c) => )} +
+ {showMainToggle && ( + + )} +
+ ); +} diff --git a/src/modules/smart/LocationsModule.tsx b/src/modules/smart/LocationsModule.tsx new file mode 100644 index 0000000..bc613c8 --- /dev/null +++ b/src/modules/smart/LocationsModule.tsx @@ -0,0 +1,27 @@ +import { MapPin } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Badge } from '@/components/ui/primitives'; +import { SmartTabs } from './SmartTabs'; +import { LocationCard } from './LocationCard'; +import { useLocations, scopedTo } from '@/data/locations'; +import { useAuth } from '@/core/store'; + +export function LocationsModule() { + const user = useAuth((s) => s.user); + const locations = scopedTo(user, useLocations((s) => s.locations)); + const onMain = locations.filter((l) => l.onMain).length; + return ( +
+ на главном: {onMain} из {locations.length}} + /> + +
+ {locations.map((l) => )} +
+
+ ); +} diff --git a/src/modules/smart/SmartModule.tsx b/src/modules/smart/SmartModule.tsx new file mode 100644 index 0000000..25b9d63 --- /dev/null +++ b/src/modules/smart/SmartModule.tsx @@ -0,0 +1,228 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Cpu, Lightbulb, ToggleRight, DoorClosed, DoorOpen, Power, ChevronDown, Lock, Clock, ExternalLink } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; +import { Card, CardHeader, Badge, StatusDot } from '@/components/ui/primitives'; +import { PropList } from '@/components/ui/Collapsible'; +import { ExportPdfButton } from '@/components/ExportPdfButton'; +import { cn } from '@/lib/utils'; +import { type WbChannel } from '@/data/mock'; +import { useSmartData } from './useSmartData'; +import { SmartTabs } from './SmartTabs'; +import { LocationCard } from './LocationCard'; +import { useLocations, scopedTo } from '@/data/locations'; +import { useAuth } from '@/core/store'; +import { can, SECTION_LABEL } from '@/core/auth'; + +const DOOR_ID = 'mr6c_214/IN1'; + +// Кросс-раздельный переход: тип события → раздел +function eventSection(type: string): { path: string; label: string } { + if (type.startsWith('door') || type.startsWith('relay')) return { path: '/smart', label: 'Смартпанель' }; + if (type.startsWith('power') || type.includes('batt') || type.includes('temp')) return { path: '/energy', label: 'Энергомониторинг' }; + if (type.startsWith('net') || type.includes('link')) return { path: '/network', label: 'Сетевая инфраструктура' }; + return { path: '/gps', label: 'GPS-мониторинг' }; +} + +function Toggle({ on, onClick, disabled }: { on: boolean; onClick: () => void; disabled?: boolean }) { + return ( + + ); +} + +const KIND_LABEL: Record = { relay: 'Реле (coil)', dimmer: 'Диммер', input: 'Вход (discrete)' }; +const MODBUS_FN: Record = { relay: '0x01/0x05 (coil)', dimmer: '0x03/0x06 (holding)', input: '0x02 (discrete input)' }; + +function ChannelRow({ ch, onToggle, canEdit }: { ch: WbChannel; onToggle: () => void; canEdit: boolean }) { + const [open, setOpen] = useState(false); + const Icon = ch.kind === 'dimmer' ? Lightbulb : ch.kind === 'input' ? DoorClosed : Power; + const [dev, reg] = ch.id.split('/'); + return ( +
+
+
+ +
+
+
{ch.name}
+
{ch.id}
+
+ {ch.kind === 'dimmer' && ch.on && {ch.level}%} + {ch.kind === 'input' ? ( + {ch.on ? 'сработал' : 'норма'} + ) : ( + + )} + +
+
+
+
+ {reg}], + ['Тип', KIND_LABEL[ch.kind]], + ['Modbus-функция', {MODBUS_FN[ch.kind]}], + ['Состояние', ch.kind === 'input' ? (ch.on ? 'сработал' : 'норма') : ch.on ? 'вкл' : 'выкл'], + ...(ch.kind === 'dimmer' ? ([['Яркость', `${ch.level}%`]] as Array<[string, string]>) : []), + ]} + /> +
+
+
+
+ ); +} + +export function SmartModule() { + const user = useAuth((s) => s.user); + const navigate = useNavigate(); + const canEdit = can(user, 'smart', 'edit'); + const canReports = can(user, 'smart', 'reports'); + const { channels: liveChannels, events, live } = useSmartData(user); + const mainLocations = scopedTo(user, useLocations((s) => s.locations)).filter((l) => l.onMain); + + const [channels, setChannels] = useState(liveChannels); + useEffect(() => setChannels(liveChannels), [liveChannels]); + const [ctx, setCtx] = useState<{ x: number; y: number; type: string } | null>(null); + + const toggle = (id: string) => { + if (!canEdit) return; + setChannels((cs) => cs.map((c) => (c.id === id && c.kind !== 'input' ? { ...c, on: !c.on } : c))); + }; + + const door = channels.find((c) => c.id === DOOR_ID); + const doorOpen = !!door?.on; + const doorEvents = events.filter((e) => e.type.startsWith('door')).slice(0, 3); + const devices = [...new Set(channels.map((c) => c.device))]; + const activeCount = channels.filter((c) => c.kind !== 'input' && c.on).length; + + return ( +
+ + {!canEdit && только просмотр} + {canReports && ( + ({ + title: 'Смартпанель — состояние каналов Wiren Board', + columns: ['Канал', 'ID', 'Тип', 'Состояние'], + rows: channels.map((c) => [c.name, c.id, c.kind === 'relay' ? 'реле' : c.kind === 'dimmer' ? 'диммер' : 'вход', c.kind === 'input' ? (c.on ? 'сработал' : 'норма') : c.on ? 'вкл' : 'выкл']), + })} + /> + )} + {live ? 'live · WS' : 'демо'} +
+ } + /> + + + + {/* Карточки локаций, отмеченных «на главный экран» (чекбокс в разделе «Локации») */} + {mainLocations.length > 0 && ( +
+ {mainLocations.map((l) => )} +
+ )} + + {/* Датчик двери — пиктограмма + мини-журнал с кросс-раздельными событиями */} + {door && ( + + {doorOpen ? 'открыто' : 'закрыто'}} /> +
+
+
+ {doorOpen ? : } +
+
+
{doorOpen ? 'Дверь открыта' : 'Дверь закрыта'}
+
+ дискретный вход на связи +
+
+
+
+
Мини-журнал · 3 последних · клик — переход в раздел
+ {doorEvents.length === 0 ? ( +

Событий пока нет

+ ) : ( +
+ {doorEvents.map((e) => ( + + ))} +
+ )} +
+
+
+ )} + +
+ {devices.map((dev) => ( + + +
+ {channels.filter((c) => c.device === dev).map((c) => ( + toggle(c.id)} canEdit={canEdit} /> + ))} +
+
+ ))} +
+ +

+ {live ? 'Данные каналов — из шлюза по WebSocket (обновление без перезагрузки).' : 'Демо-режим (mock). Прод-мост к Wiren Board A5YTKOKZ через mesh1 10.50.0.40.'} +

+ + {ctx && ( + <> +
setCtx(null)} onContextMenu={(e) => { e.preventDefault(); setCtx(null); }} /> +
+ +
+ + )} +
+ ); +} diff --git a/src/modules/smart/SmartTabs.tsx b/src/modules/smart/SmartTabs.tsx new file mode 100644 index 0000000..f9f4591 --- /dev/null +++ b/src/modules/smart/SmartTabs.tsx @@ -0,0 +1,32 @@ +import { NavLink } from 'react-router-dom'; +import { Cpu, MapPin } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Горизонтальные подпункты раздела «Смартпанель» +const TABS = [ + { to: '/smart', label: 'Смартпанель', icon: Cpu }, + { to: '/smart-locations', label: 'Локации', icon: MapPin }, +]; + +export function SmartTabs() { + return ( +
+ {TABS.map((t) => ( + + cn( + 'inline-flex items-center gap-2 rounded-lg px-3.5 py-2 text-sm font-medium transition-colors', + isActive ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:bg-muted hover:text-fg', + ) + } + > + + {t.label} + + ))} +
+ ); +} diff --git a/src/modules/smart/useSmartData.ts b/src/modules/smart/useSmartData.ts new file mode 100644 index 0000000..d88a0fc --- /dev/null +++ b/src/modules/smart/useSmartData.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { apiGet, openSocket } from '@/api/client'; +import { wbChannels, type WbChannel } from '@/data/mock'; +import type { MonitorEvent } from '@/core/types'; +import type { User } from '@/core/auth'; + +// Живые данные Смарт-панели из шлюза (каналы + события по WS). +// Обновление статусов без перезагрузки страницы. Фолбэк — демо с локальным тикером двери. +export function useSmartData(user: User): { channels: WbChannel[]; events: MonitorEvent[]; live: boolean } { + const [channels, setChannels] = useState(wbChannels); + const [events, setEvents] = useState([]); + const [live, setLive] = useState(false); + + useEffect(() => { + let stop = false; + let ws: WebSocket | undefined; + let timer: ReturnType | undefined; + + const fallback = () => { + setChannels(wbChannels.map((c) => ({ ...c }))); + setLive(false); + let open = false; + timer = setInterval(() => { + open = !open; + setChannels((cs) => cs.map((c) => (c.id === 'mr6c_214/IN1' ? { ...c, on: open } : c))); + setEvents((ev) => + [ + { + id: 'door-' + new Date().toISOString(), + entityId: 'cab-07', + callsign: 'Датчик двери · WB-MR6C #214', + severity: (open ? 'warning' : 'info') as MonitorEvent['severity'], + type: open ? 'door_open' : 'door_closed', + text: open ? 'Дверь шкафа открыта' : 'Дверь шкафа закрыта', + ts: new Date().toISOString(), + }, + ...ev, + ].slice(0, 40), + ); + }, 9000); + }; + + (async () => { + try { + const [ch, ev] = await Promise.all([apiGet('/channels'), apiGet('/events')]); + if (stop) return; + if (!ch.length) { + fallback(); + return; + } + setChannels(ch); + setEvents(ev); + setLive(true); + ws = openSocket((msg) => { + if (msg.type === 'channel') setChannels((cs) => cs.map((c) => (c.id === msg.data.id ? { ...c, ...msg.data } : c))); + else if (msg.type === 'event') setEvents((e) => [msg.data, ...e].slice(0, 50)); + }); + } catch { + if (!stop) fallback(); + } + })(); + + return () => { + stop = true; + ws?.close(); + if (timer) clearInterval(timer); + }; + }, [user]); + + return { channels, events, live }; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..5853f75 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,41 @@ +/** @type {import('tailwindcss').Config} */ +// Дизайн-система (навык ui-ux-pro-max). Нейтрали — через CSS-переменные (тема +// светлая/тёмная), акценты/статусы — фикс (одинаково читаются в обеих темах). +export default { + darkMode: 'class', + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + bg: 'rgb(var(--bg) / )', + surface: 'rgb(var(--surface) / )', + 'surface-2': 'rgb(var(--surface-2) / )', + muted: 'rgb(var(--muted) / )', + border: 'rgb(var(--border) / )', + fg: 'rgb(var(--fg) / )', + 'fg-muted': 'rgb(var(--fg-muted) / )', + primary: '#1E293B', + secondary: '#334155', + accent: '#F59E0B', + ok: '#22C55E', + warn: '#F59E0B', + crit: '#EF4444', + info: '#0EA5E9', + }, + fontFamily: { + sans: ['"Fira Sans"', 'system-ui', 'sans-serif'], + mono: ['"Fira Code"', 'ui-monospace', 'monospace'], + }, + borderRadius: { xl: '0.875rem', '2xl': '1.125rem' }, + boxShadow: { + card: '0 1px 2px rgba(2,6,23,.06), 0 8px 24px -12px rgba(2,6,23,.18)', + glow: '0 0 10px rgba(34,197,94,.35)', + }, + keyframes: { + 'pulse-dot': { '0%,100%': { opacity: '1' }, '50%': { opacity: '.35' } }, + }, + animation: { 'pulse-dot': 'pulse-dot 1.6s ease-in-out infinite' }, + }, + }, + plugins: [], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3ec161d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b7295a6 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { '@': path.resolve(__dirname, './src') }, + }, + server: { host: true, port: 5180 }, + preview: { host: true, port: 5180 }, +});