Железный Егорин — форк Орбиты: тёплый стиль (amber), логин с odyssey WebGL-молнией справа, энергомониторинг+панель

Ребренд Мониторинг Орбита -> Железный Егорин; accent green->amber #F59E0B; LoginScreen правая половина = Lightning (WebGL, hue 35 тёплый) под стеклянной формой; левая панель — тёплый градиент.
Доступ агентам: репо публичный, push german-токеном.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
German
2026-07-07 23:57:16 +03:00
co-authored by Claude Opus 4.8
commit b1c52ab748
114 changed files with 14340 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Фронт (Vite). Реальные значения — НЕ коммитить (только .env, он в .gitignore).
VITE_API_BASE=/api
# Бэкенд-шлюз (следующий этап) будет проксироваться nginx-ом на /api и /socket.
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
dist-ssr
*.local
.env
.env.*
!.env.example
.DS_Store
*.log
.vite
.gps-ingress-stage/
orbita-video/
+38
View File
@@ -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) — отпиши статус.
+12
View File
@@ -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
+46
View File
@@ -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).
+64
View File
@@ -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/<imei>/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`.
Binary file not shown.
+51
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
listener 1883
allow_anonymous true
persistence false
+9
View File
@@ -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
+153
View File
@@ -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
+11
View File
@@ -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
+39
View File
@@ -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()
+9
View File
@@ -0,0 +1,9 @@
// Вписывает settings.errorWorkflow в экспортированный воркфлоу n8n.
// Использование: node n8n-fixerr.js <workflow.json> <errorWorkflowId>
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]);
File diff suppressed because one or more lines are too long
+509
View File
@@ -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/<account>/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 <account> 18 <CID> <qual> <zone>"
# напр. "5011 18 1042 18 130 01 002"
# 2) ADM-CID Contact ID: "<ACCT> 18 <MT> <Q> <XYZ> <GG> <CCC>"
# где 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 где-то в строке.
# Ищем последовательность: <acct> 18 <q><xyz> ... либо явные поля.
# Нормализуем: убираем ведущий "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 <CRC4hex> <LEN4hex> "<id>" <seq> R<rcvr> L<line> #<account> [<data>] <ts> \x0D
# id: "SIA-DCS" | "ADM-CID" | "NULL" | ...
# data для ADM-CID: |< contact-id ...> ; для SIA-DCS: |Nri1/BA01]
# Ответ ACK: \x0A <CRC> <LEN> "ACK" <seq> R.. L.. #acct [] <ts> \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
+16
View File
@@ -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
+1
View File
File diff suppressed because one or more lines are too long
+296
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
W1VuaXRdCkRlc2NyaXB0aW9uPVBDTiBSZWNlaXZlciAoU3VyZ2FyZCA6NTExMSArIFNJQSBEQy0wOSA6NTExMikgLT4gbW9zcXVpdHRvIHZtLW10czEKQWZ0ZXI9bmV0d29yay1vbmxpbmUudGFyZ2V0CldhbnRzPW5ldHdvcmstb25saW5lLnRhcmdldAoKW1NlcnZpY2VdClR5cGU9c2ltcGxlCkV4ZWNTdGFydD0vdXNyL2Jpbi9weXRob24zIC9vcHQvcGNuLXJlY2VpdmVyL3Bjbi1yZWNlaXZlci5weQpSZXN0YXJ0PWFsd2F5cwpSZXN0YXJ0U2VjPTMKVXNlcj1yb290ClN0YW5kYXJkT3V0cHV0PWpvdXJuYWwKU3RhbmRhcmRFcnJvcj1qb3VybmFsCgpbSW5zdGFsbF0KV2FudGVkQnk9bXVsdGktdXNlci50YXJnZXQK
+10
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Fira+Sans:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>Железный Егорин — Смартпанель</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22
View File
@@ -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"; }
}
+3411
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+633
View File
@@ -0,0 +1,633 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#0c1014">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>ORBIT · GPS-мониторинг</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<style>
:root{
--bg:#0c1014; --panel:#141b22; --panel2:#1b242d; --line:#26323d;
--txt:#cfe3d6; --dim:#7d94a3; --accent:#3fd06b; --amber:#f4b740;
--blue:#4da3ff; --red:#ff5d5d; --grey:#5c7180;
--safe-b:env(safe-area-inset-bottom,0px); --safe-t:env(safe-area-inset-top,0px);
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--txt);
font-family:'Inter',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;font-size:14px}
#map{position:fixed;inset:0}
.maplibregl-ctrl-attrib{background:rgba(12,16,20,.6)!important;color:var(--dim)}
.maplibregl-ctrl-attrib a{color:var(--dim)}
/* ── верхняя панель поиска ── */
.topbar{position:fixed;top:calc(10px + var(--safe-t));left:12px;right:12px;z-index:30;
display:flex;gap:8px;align-items:center;pointer-events:none}
.search{flex:1;max-width:420px;display:flex;align-items:center;gap:8px;pointer-events:auto;
background:rgba(20,27,34,.92);border:1px solid var(--line);border-radius:12px;
padding:10px 14px;backdrop-filter:blur(8px)}
.search input{flex:1;background:none;border:none;outline:none;color:var(--txt);font-size:14px}
.search input::placeholder{color:var(--dim)}
.logo{pointer-events:auto;background:rgba(20,27,34,.92);border:1px solid var(--line);
border-radius:12px;padding:10px 12px;font-weight:800;letter-spacing:.12em;color:var(--accent);font-size:13px}
/* ── FAB-кнопки ── */
.fabs{position:fixed;right:12px;top:calc(70px + var(--safe-t));z-index:30;display:flex;flex-direction:column;gap:10px}
.fab{width:44px;height:44px;border-radius:12px;border:1px solid var(--line);cursor:pointer;
background:rgba(20,27,34,.92);color:var(--txt);font-size:19px;backdrop-filter:blur(8px);
display:flex;align-items:center;justify-content:center}
.fab:active{background:var(--panel2)}
.fab.on{border-color:var(--accent);color:var(--accent)}
/* ── popover настроек карты ── */
.map-settings{position:fixed;right:64px;top:calc(70px + var(--safe-t));z-index:35;width:230px;
background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;display:none}
.map-settings.open{display:block}
.map-settings h4{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin-bottom:10px}
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;cursor:pointer}
.switch{width:38px;height:22px;border-radius:11px;background:var(--line);position:relative;transition:.2s;flex:none}
.switch::after{content:'';position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;
background:var(--dim);transition:.2s}
.toggle-row.on .switch{background:rgba(63,208,107,.3)}
.toggle-row.on .switch::after{left:19px;background:var(--accent)}
/* ── легенда ── */
.legend{position:fixed;left:12px;bottom:calc(70px + var(--safe-b));z-index:25;
background:rgba(20,27,34,.92);border:1px solid var(--line);border-radius:12px;
padding:10px 12px;display:flex;flex-direction:column;gap:6px;font-size:12px;backdrop-filter:blur(8px)}
.legend .li{display:flex;align-items:center;gap:8px;color:var(--dim)}
.legend .dot{width:10px;height:10px;border-radius:50%}
/* ── маркеры ── */
.donut{cursor:pointer;filter:drop-shadow(0 2px 6px rgba(0,0,0,.5))}
.pin{cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:3px}
.pin-dot{width:30px;height:30px;border-radius:50%;border:2.5px solid;background:var(--panel);
display:flex;align-items:center;justify-content:center;font-size:13px;
box-shadow:0 2px 8px rgba(0,0,0,.55)}
.pin-moving .pin-dot{border-color:var(--accent);color:var(--accent)}
.pin-parked .pin-dot{border-color:var(--amber);color:var(--amber)}
.pin-offline .pin-dot{border-color:var(--grey);color:var(--grey)}
.pin-alarm .pin-dot{border-color:var(--red);color:var(--red);animation:pulse 1.1s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(255,93,93,.55)}70%{box-shadow:0 0 0 12px rgba(255,93,93,0)}100%{box-shadow:0 0 0 0 rgba(255,93,93,0)}}
.pin-label{background:rgba(12,16,20,.85);border:1px solid var(--line);border-radius:6px;
padding:2px 7px;font-size:11px;white-space:nowrap;color:var(--txt)}
body.no-labels .pin-label{display:none}
.pin-arrow{display:inline-block;transition:transform .4s linear}
/* ── карточка объекта / bottom sheet ── */
.card{position:fixed;z-index:40;background:var(--panel);border:1px solid var(--line);
display:none;flex-direction:column;overflow:hidden}
.card.open{display:flex}
.card-handle{display:none}
.card-head{padding:14px 16px 10px;display:flex;align-items:flex-start;gap:10px}
.card-head h3{font-size:16px;flex:1}
.card-head .sub{color:var(--dim);font-size:12px;margin-top:2px}
.card-close{background:var(--panel2);border:1px solid var(--line);color:var(--dim);
width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:14px;flex:none}
.banner{margin:0 16px;padding:10px 12px;border-radius:10px;font-weight:600;font-size:13px;
display:flex;justify-content:space-between;align-items:center;gap:8px}
.banner small{font-weight:400;opacity:.75;font-size:11px}
.banner.moving{background:rgba(63,208,107,.14);color:var(--accent)}
.banner.parked{background:rgba(244,183,64,.14);color:var(--amber)}
.banner.offline{background:rgba(92,113,128,.2);color:var(--grey)}
.banner.alarm{background:rgba(255,93,93,.16);color:var(--red)}
.tabs{display:flex;gap:4px;padding:12px 16px 0}
.tab{flex:1;text-align:center;padding:8px 4px;border-radius:8px;cursor:pointer;
color:var(--dim);font-size:13px;border:1px solid transparent}
.tab.act{background:var(--panel2);color:var(--txt);border-color:var(--line)}
.card-body{padding:12px 16px;overflow-y:auto;flex:1;min-height:120px}
.row{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--line);font-size:13px}
.row:last-child{border-bottom:none}
.row .k{color:var(--dim)}
.trip{padding:9px 0;border-bottom:1px solid var(--line);font-size:13px}
.trip:last-child{border-bottom:none}
.trip .t2{color:var(--dim);font-size:12px;margin-top:2px}
.card-actions{display:flex;gap:8px;padding:12px 16px calc(14px + var(--safe-b))}
.btn{flex:1;padding:11px 6px;border-radius:10px;border:1px solid var(--line);cursor:pointer;
background:var(--panel2);color:var(--txt);font-size:13px;font-weight:600}
.btn.primary{background:rgba(63,208,107,.16);border-color:rgba(63,208,107,.4);color:var(--accent)}
/* ── список объектов ── */
.objlist{position:fixed;z-index:38;background:var(--panel);border:1px solid var(--line);
display:none;flex-direction:column;overflow:hidden}
.objlist.open{display:flex}
.objlist-head{padding:14px 16px;font-weight:700;display:flex;justify-content:space-between;align-items:center}
.objlist-body{overflow-y:auto;flex:1}
.obj-item{display:flex;align-items:center;gap:10px;padding:11px 16px;cursor:pointer;border-top:1px solid var(--line)}
.obj-item:active{background:var(--panel2)}
.obj-item .dot{width:10px;height:10px;border-radius:50%;flex:none}
.obj-item .nm{flex:1}
.obj-item .st{font-size:12px;color:var(--dim)}
/* ── тост ── */
.toast{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(84px + var(--safe-b));z-index:60;
background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:10px 16px;
border-radius:10px;font-size:13px;opacity:0;transition:.3s;pointer-events:none}
.toast.show{opacity:1}
/* ══ МОБИЛЬНЫЙ (< 1024px): таб-бар + bottom sheet ══ */
@media (max-width:1023px){
.rail{display:none}
.tabbar{position:fixed;left:0;right:0;bottom:0;z-index:45;display:flex;
background:rgba(20,27,34,.96);border-top:1px solid var(--line);
padding:6px 4px calc(6px + var(--safe-b));backdrop-filter:blur(10px)}
.tb{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;cursor:pointer;
color:var(--dim);font-size:10px;padding:4px 0;border:none;background:none}
.tb .ic{font-size:19px}
.tb.act{color:var(--accent)}
.card{left:0;right:0;bottom:0;border-radius:18px 18px 0 0;border-bottom:none;
max-height:46vh;transition:max-height .25s ease}
.card.expanded{max-height:86vh}
.card-handle{display:block;width:44px;height:5px;border-radius:3px;background:var(--line);
margin:8px auto 0;cursor:grab}
.objlist{left:0;right:0;bottom:0;border-radius:18px 18px 0 0;max-height:70vh;
padding-bottom:calc(56px + var(--safe-b))}
.legend{bottom:calc(76px + var(--safe-b))}
}
/* ══ ДЕСКТОП (≥ 1024px): рейка + плавающие карточки ══ */
@media (min-width:1024px){
.tabbar{display:none}
.rail{position:fixed;left:0;top:0;bottom:0;width:60px;z-index:45;display:flex;
flex-direction:column;align-items:center;gap:6px;padding:14px 0;
background:var(--panel);border-right:1px solid var(--line)}
.rail .rl{width:42px;height:42px;border-radius:10px;display:flex;align-items:center;
justify-content:center;font-size:19px;color:var(--dim);cursor:pointer;border:1px solid transparent;background:none}
.rail .rl.act{color:var(--accent);background:var(--panel2);border-color:var(--line)}
.rail .sp{flex:1}
.topbar{left:74px}
.legend{left:74px;bottom:16px}
.card{right:12px;top:calc(70px + var(--safe-t));bottom:16px;width:360px;border-radius:16px}
.objlist{left:74px;top:calc(70px + var(--safe-t));bottom:16px;width:320px;border-radius:16px}
}
</style>
</head>
<body>
<div id="map"></div>
<div class="topbar">
<div class="logo">ORBIT</div>
<div class="search">🔍 <input id="search" type="search" placeholder="Поиск объекта…" autocomplete="off"></div>
</div>
<div class="fabs">
<button class="fab" id="fabLocate" title="Моё местоположение">◎</button>
<button class="fab" id="fabSettings" title="Настройки карты">✦</button>
</div>
<div class="map-settings" id="mapSettings">
<h4>Настройки карты</h4>
<div class="toggle-row" id="tgl3d"><span>3D-здания</span><span class="switch"></span></div>
<div class="toggle-row on" id="tglLabels"><span>Подписи объектов</span><span class="switch"></span></div>
</div>
<div class="legend">
<div class="li"><span class="dot" style="background:var(--accent)"></span>В движении</div>
<div class="li"><span class="dot" style="background:var(--amber)"></span>Стоянка / на связи</div>
<div class="li"><span class="dot" style="background:var(--grey)"></span>Офлайн</div>
<div class="li"><span class="dot" style="background:var(--red)"></span>Тревога</div>
</div>
<!-- карточка объекта -->
<div class="card" id="card">
<div class="card-handle" id="cardHandle"></div>
<div class="card-head">
<div style="flex:1"><h3 id="cName">—</h3><div class="sub" id="cAddr">—</div></div>
<button class="card-close" id="cardClose">✕</button>
</div>
<div class="banner moving" id="cBanner"><span id="cBannerText">—</span><small id="cUpdated"></small></div>
<div class="tabs">
<div class="tab act" data-tab="status">Статус</div>
<div class="tab" data-tab="telemetry">Телеметрия</div>
<div class="tab" data-tab="trips">Поездки</div>
</div>
<div class="card-body" id="cBody"></div>
<div class="card-actions">
<button class="btn primary" data-act="route">Маршрут</button>
<button class="btn" data-act="radio">Рация</button>
<button class="btn" data-act="report">Отчёт</button>
</div>
</div>
<!-- список объектов -->
<div class="objlist" id="objlist">
<div class="objlist-head"><span>Объекты</span><button class="card-close" id="objClose">✕</button></div>
<div class="objlist-body" id="objBody"></div>
</div>
<!-- десктопная рейка -->
<div class="rail">
<button class="rl" id="rlPanel" title="Смартпанель">⌂</button>
<button class="rl act" title="Карта">🗺</button>
<button class="rl" id="rlObjects" title="Объекты">☰</button>
<button class="rl" title="Треки">〜</button>
<button class="rl" title="Рация">🎙</button>
<div class="sp"></div>
</div>
<!-- мобильный таб-бар -->
<div class="tabbar">
<button class="tb act"><span class="ic">🗺</span>Карта</button>
<button class="tb" id="tbObjects"><span class="ic">☰</span>Объекты</button>
<button class="tb" id="tbRadio"><span class="ic">🎙</span>Рация</button>
<button class="tb" id="tbPanel"><span class="ic">⌂</span>Панель</button>
</div>
<div class="toast" id="toast"></div>
<script>
'use strict';
/* ═══ ORBIT GPS · map-first прототип · демо-данные КМВ ═══
Для прода: vehicles[] и симуляция заменяются на Traccar WebSocket (/api/socket). */
const COLORS = {moving:'#3fd06b', parked:'#f4b740', offline:'#5c7180', alarm:'#ff5d5d'};
const STATUS_TXT = {moving:'В движении', parked:'Стоянка / на связи', offline:'Нет связи', alarm:'Тревога! Требует внимания'};
/* ── демо-объекты: ГАЗели + щитовые шкафы, Кавминводы ── */
const vehicles = [
{id:1, name:'ГАЗель-101', icon:'▲', status:'moving', speed:64, heading:75, lng:43.020, lat:44.035, addr:'трасса А-157, Ессентуки → Пятигорск', drv:'Сергеев А.'},
{id:2, name:'ГАЗель-102', icon:'▲', status:'moving', speed:47, heading:210, lng:43.078, lat:44.052, addr:'Пятигорск, ул. Ермолова', drv:'Ковалёв Д.'},
{id:3, name:'ГАЗель-103', icon:'▲', status:'moving', speed:58, heading:130, lng:42.880, lat:44.048, addr:'Ессентуки, ул. Пятигорская', drv:'Мирзоев Р.'},
{id:4, name:'ГАЗель-104', icon:'▲', status:'moving', speed:71, heading:20, lng:43.100, lat:44.150, addr:'трасса, Минводы', drv:'Литвинов С.'},
{id:5, name:'ГАЗель-105', icon:'▲', status:'parked', speed:0, heading:0, lng:43.058, lat:44.040, addr:'Пятигорск, база, пр. Калинина 2', drv:'—'},
{id:6, name:'ШУ-Пятигорск-1', icon:'⚡', status:'parked', speed:0, heading:0, lng:43.046, lat:44.049, addr:'Пятигорск, ул. Крайнего 49'},
{id:7, name:'ШУ-Пятигорск-2', icon:'⚡', status:'parked', speed:0, heading:0, lng:43.070, lat:44.032, addr:'Пятигорск, ул. Адмиральского 8'},
{id:8, name:'ШУ-Ессентуки-1', icon:'⚡', status:'alarm', speed:0, heading:0, lng:42.858, lat:44.044, addr:'Ессентуки, ул. Интернациональная 12'},
{id:9, name:'ШУ-Кисловодск-1', icon:'⚡', status:'parked', speed:0, heading:0, lng:42.716, lat:43.905, addr:'Кисловодск, пр. Победы 37'},
{id:10,name:'ШУ-Кисловодск-2', icon:'⚡', status:'offline', speed:0, heading:0, lng:42.728, lat:43.912, addr:'Кисловодск, ул. Жуковского 14'},
{id:11,name:'ШУ-Минводы-1', icon:'⚡', status:'parked', speed:0, heading:0, lng:43.135, lat:44.210, addr:'Минеральные Воды, ул. Советская 50'},
{id:12,name:'ШУ-Железноводск', icon:'⚡', status:'offline', speed:0, heading:0, lng:43.031, lat:44.132, addr:'Железноводск, ул. Ленина 106'},
{id:13,name:'ГАЗель-106', icon:'▲', status:'alarm', speed:0, heading:0, lng:42.905, lat:44.060, addr:'съезд с А-157 (кнопка SOS)', drv:'Носов В.'},
{id:14,name:'ШУ-Пятигорск-3', icon:'⚡', status:'parked', speed:0, heading:0, lng:43.090, lat:44.041, addr:'Пятигорск, ул. Univermag 3'}
];
function fc(){
return {type:'FeatureCollection', features: vehicles.map(v => ({
type:'Feature',
geometry:{type:'Point', coordinates:[v.lng, v.lat]},
properties:{id:v.id, status:v.status}
}))};
}
/* ── карта: CARTO dark растр + вектор OpenFreeMap для 3D ── */
const map = new maplibregl.Map({
container:'map',
center:[43.00, 44.04],
zoom:10.4,
attributionControl:{compact:true},
style:{
version:8,
sources:{
carto:{
type:'raster', tileSize:256,
tiles:['a','b','c','d'].map(s => 'https://'+s+'.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'),
attribution:'© OpenStreetMap © CARTO'
},
openfreemap:{type:'vector', url:'https://tiles.openfreemap.org/planet'}
},
layers:[{id:'base', type:'raster', source:'carto'}]
}
});
map.touchZoomRotate.enableRotation();
let settings = {b3d:false, labels:true};
let autoTilted = false;
map.on('load', () => {
map.addSource('vehicles', {
type:'geojson', data:fc(),
cluster:true, clusterMaxZoom:13, clusterRadius:56,
clusterProperties:{
moving: ['+', ['case', ['==', ['get','status'], 'moving'], 1, 0]],
parked: ['+', ['case', ['==', ['get','status'], 'parked'], 1, 0]],
offline: ['+', ['case', ['==', ['get','status'], 'offline'], 1, 0]],
alarm: ['+', ['case', ['==', ['get','status'], 'alarm'], 1, 0]]
}
});
/* невидимый слой — заставляет источник грузить тайлы */
map.addLayer({id:'veh-stub', type:'circle', source:'vehicles',
paint:{'circle-radius':0, 'circle-opacity':0}});
map.on('render', updateMarkers);
map.on('zoomend', autoTilt);
});
/* ── 3D-здания ── */
function set3d(on){
settings.b3d = on;
if(on && !map.getLayer('b3d')){
map.addLayer({
id:'b3d', type:'fill-extrusion', source:'openfreemap', 'source-layer':'building', minzoom:14,
paint:{
'fill-extrusion-color':'#2c3a47',
'fill-extrusion-height':['coalesce', ['get','render_height'], 12],
'fill-extrusion-base':['coalesce', ['get','render_min_height'], 0],
'fill-extrusion-opacity':0.78
}
}, 'veh-stub');
map.easeTo({pitch:52, duration:600});
} else if(!on && map.getLayer('b3d')){
map.removeLayer('b3d');
map.easeTo({pitch:0, duration:600});
autoTilted = false;
}
}
function autoTilt(){
if(!settings.b3d) return;
const z = map.getZoom();
if(z >= 15.5 && map.getPitch() < 25){ map.easeTo({pitch:52, duration:500}); autoTilted = true; }
else if(z < 13.5 && autoTilted){ map.easeTo({pitch:0, duration:500}); autoTilted = false; }
}
/* ── donut-кластеры ── */
function donutSegment(start, end, r, r0, color){
if(end - start === 1) end -= 0.00001;
const a0 = 2*Math.PI*(start - 0.25), a1 = 2*Math.PI*(end - 0.25);
const x0 = Math.cos(a0), y0 = Math.sin(a0), x1 = Math.cos(a1), y1 = Math.sin(a1);
const large = end - start > 0.5 ? 1 : 0;
return '<path d="M ' + (r + r0*x0) + ' ' + (r + r0*y0) + ' L ' + (r + r*x0) + ' ' + (r + r*y0) +
' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + (r + r*x1) + ' ' + (r + r*y1) +
' L ' + (r + r0*x1) + ' ' + (r + r0*y1) + ' A ' + r0 + ' ' + r0 + ' 0 ' + large + ' 0 ' +
(r + r0*x0) + ' ' + (r + r0*y0) + '" fill="' + color + '"/>';
}
function createDonut(props){
const keys = ['moving','parked','offline','alarm'];
const total = props.point_count;
const r = total >= 15 ? 27 : total >= 8 ? 23 : 19;
const r0 = Math.round(r * 0.62), w = r * 2;
let svg = '<svg width="' + w + '" height="' + w + '" viewBox="0 0 ' + w + ' ' + w + '">';
let offset = 0;
for(const k of keys){
const n = props[k] || 0;
if(!n) continue;
svg += donutSegment(offset/total, (offset + n)/total, r, r0, COLORS[k]);
offset += n;
}
svg += '<circle cx="' + r + '" cy="' + r + '" r="' + r0 + '" fill="#141b22"/>' +
'<text x="' + r + '" y="' + r + '" dominant-baseline="central" text-anchor="middle" fill="#cfe3d6" ' +
'font-size="' + Math.round(r*0.58) + '" font-weight="700" font-family="Inter,system-ui">' + total + '</text></svg>';
const el = document.createElement('div');
el.className = 'donut';
el.innerHTML = svg;
return el;
}
/* ── маркеры: кластеры + отдельные объекты ── */
const markers = {};
let markersOnScreen = {};
function createPin(v){
const el = document.createElement('div');
el.className = 'pin pin-' + v.status;
el.innerHTML = '<div class="pin-dot"><span class="pin-arrow">' + v.icon + '</span></div>' +
'<div class="pin-label">' + v.name + '</div>';
el.addEventListener('click', e => { e.stopPropagation(); openCard(v.id); });
return el;
}
function refreshPin(el, v){
el.className = 'pin pin-' + v.status;
const arrow = el.querySelector('.pin-arrow');
arrow.style.transform = (v.status === 'moving') ? 'rotate(' + v.heading + 'deg)' : 'none';
}
function updateMarkers(){
if(!map.getSource('vehicles') || !map.isSourceLoaded('vehicles')) return;
const newOnScreen = {};
const feats = map.querySourceFeatures('vehicles');
for(const f of feats){
const coords = f.geometry.coordinates;
const p = f.properties;
let id, m;
if(p.cluster){
id = 'c' + p.cluster_id;
m = markers[id];
const sig = [p.point_count, p.moving, p.parked, p.offline, p.alarm].join('|');
if(m && m._sig !== sig){ m.remove(); delete markers[id]; m = null; delete markersOnScreen[id]; }
if(!m){
const el = createDonut(p);
el.addEventListener('click', e => {
e.stopPropagation();
map.getSource('vehicles').getClusterExpansionZoom(p.cluster_id)
.then(z => map.easeTo({center:coords, zoom:z + 0.4, duration:500}));
});
m = markers[id] = new maplibregl.Marker({element:el}).setLngLat(coords);
m._sig = sig;
}
} else {
id = 'v' + p.id;
const v = vehicles.find(x => x.id === p.id);
if(!v) continue;
m = markers[id];
if(!m){
m = markers[id] = new maplibregl.Marker({element:createPin(v)}).setLngLat(coords);
} else {
m.setLngLat([v.lng, v.lat]);
refreshPin(m.getElement(), v);
}
}
newOnScreen[id] = m;
if(!markersOnScreen[id]) m.addTo(map);
}
for(const id in markersOnScreen){
if(!newOnScreen[id]) markersOnScreen[id].remove();
}
markersOnScreen = newOnScreen;
}
/* ── живая демо-симуляция ── */
let lastUpdate = Date.now();
setInterval(() => {
for(const v of vehicles){
if(v.status !== 'moving') continue;
v.heading = (v.heading + (Math.random() - 0.5) * 14 + 360) % 360;
v.speed = Math.max(20, Math.min(85, v.speed + (Math.random() - 0.5) * 6));
const d = v.speed / 3600 / 111 * 1.5; /* км/ч → градусы за 1.5 c */
v.lng += d * Math.sin(v.heading * Math.PI/180) / Math.cos(v.lat * Math.PI/180);
v.lat += d * Math.cos(v.heading * Math.PI/180);
}
lastUpdate = Date.now();
const src = map.getSource('vehicles');
if(src) src.setData(fc());
if(openId){
const v = vehicles.find(x => x.id === openId);
if(v && v.status === 'moving') fillCard(v);
}
}, 1500);
/* ── карточка объекта ── */
const card = document.getElementById('card');
let openId = null, curTab = 'status';
function openCard(id){
openId = id;
curTab = 'status';
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('act', t.dataset.tab === 'status'));
const v = vehicles.find(x => x.id === id);
fillCard(v);
card.classList.add('open');
closeObjList();
map.easeTo({center:[v.lng, v.lat], duration:500});
}
function fillCard(v){
document.getElementById('cName').textContent = v.name;
document.getElementById('cAddr').textContent = v.addr;
const b = document.getElementById('cBanner');
b.className = 'banner ' + v.status;
document.getElementById('cBannerText').textContent =
v.status === 'moving' ? 'В движении — ' + Math.round(v.speed) + ' км/ч' : STATUS_TXT[v.status];
const ago = Math.max(1, Math.round((Date.now() - lastUpdate)/1000));
document.getElementById('cUpdated').textContent =
v.status === 'offline' ? 'связь потеряна 2 ч назад' : 'обновлено ' + ago + ' с назад';
renderTab(v);
}
function renderTab(v){
const el = document.getElementById('cBody');
if(curTab === 'status'){
el.innerHTML =
row('Статус', STATUS_TXT[v.status]) +
row('Скорость', Math.round(v.speed) + ' км/ч') +
(v.status === 'moving' ? row('Курс', Math.round(v.heading) + '°') : '') +
(v.drv ? row('Водитель', v.drv) : row('Тип', 'Щитовой шкаф')) +
row('Зажигание / питание', v.status === 'moving' ? 'Вкл' : (v.status === 'offline' ? '—' : 'Сеть 220В')) +
row('Координаты', v.lat.toFixed(5) + ', ' + v.lng.toFixed(5));
} else if(curTab === 'telemetry'){
el.innerHTML =
row('U питания', v.status === 'offline' ? '—' : (v.drv ? '13.8 В' : '224 В')) +
row('U батареи', v.status === 'offline' ? '—' : '4.1 В') +
row('GSM', v.status === 'offline' ? 'нет сети' : '-67 дБм (4G)') +
row('Спутники / HDOP', v.status === 'offline' ? '—' : '14 / 0.8') +
row('Температура', v.drv ? '31 °C' : '38 °C') +
row('Датчик двери', v.status === 'alarm' ? '⚠ ОТКРЫТА' : 'закрыта');
} else {
el.innerHTML = v.drv
? trip('Сегодня 08:12 – 09:40', 'База → Ессентуки, 34 км, макс 82 км/ч') +
trip('Сегодня 10:05 – 11:12', 'Ессентуки → Кисловодск, 28 км') +
trip('Вчера 16:40 – 18:02', 'Кисловодск → База, 41 км')
: trip('Стационарный объект', 'Поездки не регистрируются') +
trip('Геозона-радиус 150 м', v.status === 'alarm' ? '⚠ сработка датчика' : 'нарушений нет');
}
}
function row(k, val){ return '<div class="row"><span class="k">' + k + '</span><span>' + val + '</span></div>'; }
function trip(t, d){ return '<div class="trip">' + t + '<div class="t2">' + d + '</div></div>'; }
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
curTab = t.dataset.tab;
document.querySelectorAll('.tab').forEach(x => x.classList.toggle('act', x === t));
const v = vehicles.find(x => x.id === openId);
if(v) renderTab(v);
}));
document.getElementById('cardClose').addEventListener('click', () => {
card.classList.remove('open', 'expanded');
openId = null;
});
document.getElementById('cardHandle').addEventListener('click', () => card.classList.toggle('expanded'));
let dragY = null;
document.getElementById('cardHandle').addEventListener('touchstart', e => { dragY = e.touches[0].clientY; }, {passive:true});
document.getElementById('cardHandle').addEventListener('touchend', e => {
if(dragY === null) return;
const dy = e.changedTouches[0].clientY - dragY;
if(dy < -30) card.classList.add('expanded');
else if(dy > 30){
if(card.classList.contains('expanded')) card.classList.remove('expanded');
else { card.classList.remove('open'); openId = null; }
}
dragY = null;
}, {passive:true});
document.querySelectorAll('.card-actions .btn').forEach(b => b.addEventListener('click', () => {
const v = vehicles.find(x => x.id === openId);
const nm = v ? v.name : '';
const msg = {route:'Маршрут до «' + nm + '» (демо)', radio:'Радиосеанс с «' + nm + '» (демо)', report:'Отчёт по «' + nm + '» (демо)'};
showToast(msg[b.dataset.act]);
}));
/* ── список объектов ── */
const objlist = document.getElementById('objlist');
function renderObjList(filter){
const q = (filter || '').toLowerCase();
document.getElementById('objBody').innerHTML = vehicles
.filter(v => !q || v.name.toLowerCase().includes(q) || v.addr.toLowerCase().includes(q))
.map(v => '<div class="obj-item" data-id="' + v.id + '">' +
'<span class="dot" style="background:' + COLORS[v.status] + '"></span>' +
'<span class="nm">' + v.name + '</span><span class="st">' +
(v.status === 'moving' ? Math.round(v.speed) + ' км/ч' : STATUS_TXT[v.status].split('!')[0]) +
'</span></div>').join('');
document.querySelectorAll('.obj-item').forEach(it => it.addEventListener('click', () => {
const v = vehicles.find(x => x.id === +it.dataset.id);
map.flyTo({center:[v.lng, v.lat], zoom:15, duration:700});
openCard(v.id);
}));
}
function openObjList(){ renderObjList(document.getElementById('search').value); objlist.classList.add('open'); }
function closeObjList(){ objlist.classList.remove('open'); }
document.getElementById('objClose').addEventListener('click', closeObjList);
document.getElementById('tbObjects').addEventListener('click', () => {
objlist.classList.contains('open') ? closeObjList() : openObjList();
});
document.getElementById('rlObjects').addEventListener('click', () => {
objlist.classList.contains('open') ? closeObjList() : openObjList();
});
/* ── поиск ── */
document.getElementById('search').addEventListener('input', e => {
if(objlist.classList.contains('open')) renderObjList(e.target.value);
});
document.getElementById('search').addEventListener('keydown', e => {
if(e.key !== 'Enter') return;
const q = e.target.value.toLowerCase().trim();
if(!q) return;
const v = vehicles.find(x => x.name.toLowerCase().includes(q));
if(v){ map.flyTo({center:[v.lng, v.lat], zoom:15, duration:700}); openCard(v.id); }
else showToast('Объект не найден');
e.target.blur();
});
/* ── FAB: геолокация и настройки ── */
document.getElementById('fabLocate').addEventListener('click', () => {
if(navigator.geolocation){
navigator.geolocation.getCurrentPosition(
pos => map.flyTo({center:[pos.coords.longitude, pos.coords.latitude], zoom:14, duration:800}),
() => { showToast('Геолокация недоступна — центр района'); map.flyTo({center:[43.00, 44.04], zoom:10.4, duration:800}); }
);
} else map.flyTo({center:[43.00, 44.04], zoom:10.4, duration:800});
});
const settingsEl = document.getElementById('mapSettings');
document.getElementById('fabSettings').addEventListener('click', e => {
e.stopPropagation();
settingsEl.classList.toggle('open');
document.getElementById('fabSettings').classList.toggle('on', settingsEl.classList.contains('open'));
});
document.addEventListener('click', e => {
if(!settingsEl.contains(e.target)){
settingsEl.classList.remove('open');
document.getElementById('fabSettings').classList.remove('on');
}
});
document.getElementById('tgl3d').addEventListener('click', function(){
this.classList.toggle('on');
set3d(this.classList.contains('on'));
});
document.getElementById('tglLabels').addEventListener('click', function(){
this.classList.toggle('on');
settings.labels = this.classList.contains('on');
document.body.classList.toggle('no-labels', !settings.labels);
});
/* ── навигация ── */
document.getElementById('tbRadio').addEventListener('click', () => showToast('Режим «Рация» — в основной панели'));
document.getElementById('tbPanel').addEventListener('click', () => showToast('Переход на смартпанель (демо)'));
document.getElementById('rlPanel').addEventListener('click', () => showToast('Переход на смартпанель (демо)'));
/* ── тост ── */
let toastTimer = null;
function showToast(text){
const t = document.getElementById('toast');
t.textContent = text;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2200);
}
</script>
</body>
</html>
+20
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules
.env
.env.*
!.env.example
*.log
+7
View File
@@ -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"]
+18
View File
@@ -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"
}
}
+38
View File
@@ -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;
}
+50
View File
@@ -0,0 +1,50 @@
// MQTT-адаптер: приём позиций от FLEX-сервера (navtelecom/<imei>/state) +
// состояние каналов Wiren Board (/devices/<dev>/controls/<ch>).
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;
}
+34
View File
@@ -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;
}
+89
View File
@@ -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));
}
+38
View File
@@ -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,
};
+77
View File
@@ -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);
}
+78
View File
@@ -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 '—';
}
+197
View File
@@ -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));
+77
View File
@@ -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);
+180
View File
@@ -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;
}
+61
View File
@@ -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<string | null>(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=<userId> — ТОЛЬКО в 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 (
<div className="grid min-h-dvh place-items-center bg-bg">
<Activity className="h-8 w-8 animate-pulse text-accent" />
</div>
);
}
if (!authed) return <LoginScreen error={authError} />;
const mods = visibleModules(user);
const home = mods[0]?.path ?? '/access';
return (
<Routes>
<Route element={<AppShell />}>
{mods.map((m) => (
<Route key={m.id} path={m.path} element={<m.component />} />
))}
<Route path="*" element={<Navigate to={home} replace />} />
</Route>
</Routes>
);
}
+75
View File
@@ -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<T>(path: string): Promise<T> {
const r = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${token}` } });
if (!r.ok) throw new Error(String(r.status));
return r.json() as Promise<T>;
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
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<T>;
}
export async function apiDelete<T = void>(path: string): Promise<T> {
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<any>('/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;
}
+17
View File
@@ -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 (
<button
type="button"
onClick={() => exportPdf(build(), sectionTitle, user)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-fg hover:bg-muted"
title="Выгрузить отчёт раздела в PDF"
>
<FileText className="h-3.5 w-3.5" /> Отчёт PDF
</button>
);
}
+94
View File
@@ -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<HTMLCanvasElement>(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<OCTAVE_COUNT;++i){value+=amplitude*noise(p);p*=rotate2d(0.45);p*=2.0;amplitude*=0.5;}return value;}
void mainImage(out vec4 fragColor,in vec2 fragCoord){vec2 uv=fragCoord/iResolution.xy;uv=2.0*uv-1.0;uv.x*=iResolution.x/iResolution.y;uv.x+=uXOffset;uv+=2.0*fbm(uv*uSize+0.8*iTime*uSpeed)-1.0;float dist=abs(uv.x);vec3 baseColor=hsv2rgb(vec3(uHue/360.0,0.7,0.8));vec3 col=baseColor*pow(mix(0.0,0.07,hash11(iTime*uSpeed))/dist,1.0)*uIntensity;col=pow(col,vec3(1.0));fragColor=vec4(col,1.0);}
void main(){mainImage(gl_FragColor,gl_FragCoord.xy);}`;
const compile = (src: string, type: number) => {
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 <canvas ref={canvasRef} className={className ?? 'w-full h-full'} />;
}
+195
View File
@@ -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 (
<div className="overflow-hidden rounded-xl border border-white/5 bg-white/[0.03] transition-colors hover:border-accent/30">
<button type="button" onClick={() => setOpen((v) => !v)} className="flex w-full items-center gap-3 px-4 py-3 text-left transition-transform hover:translate-x-0.5">
<div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-accent/15">
<f.icon className="h-4 w-4 text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-white">{f.title}</div>
<div className="truncate text-xs text-slate-400">{f.desc}</div>
</div>
<ChevronDown className={`h-4 w-4 shrink-0 text-slate-400 transition-transform duration-300 ${open ? 'rotate-180' : ''}`} />
</button>
<div className={`grid transition-all duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
<div className="overflow-hidden">
<div className="px-4 pb-4">
<ul className="space-y-1.5">
{f.options.map((o) => (
<li key={o} className="flex items-start gap-2 text-xs text-slate-300">
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-accent" />
{o}
</li>
))}
</ul>
<div className="mt-3">
<button
type="button"
onClick={() => setContact((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-xs font-semibold text-[#06210f] transition-transform hover:-translate-y-0.5"
>
<Send className="h-3.5 w-3.5" /> Отправить запрос КП
</button>
{contact && (
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-white/10 bg-white/[0.05] p-2.5">
<a href="tel:+79620151414" className="flex items-center gap-2 text-xs font-medium text-white transition-colors hover:text-accent">
<Phone className="h-3.5 w-3.5 text-accent" /> +7 962 015-14-14
</a>
<a href={mail} className="flex items-center gap-2 text-xs font-medium text-white transition-colors hover:text-accent">
<Mail className="h-3.5 w-3.5 text-accent" /> info@servaki.online
</a>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
const AUTH_ERRORS: Record<string, string> = {
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 (
<div className="flex min-h-dvh bg-bg">
{/* Брендовая панель */}
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-gradient-to-br from-[#1a1206] via-[#231708] to-[#160c04] p-10 lg:flex">
<svg className="pointer-events-none absolute inset-0 h-full w-full opacity-40" preserveAspectRatio="xMidYMid slice" viewBox="0 0 400 600" aria-hidden>
<g stroke="#1c3556" strokeWidth="0.7" fill="none">
<path d="M60 80 L150 50 L320 90 M60 80 L90 200 L230 180 L150 50 M320 90 L350 230 L230 180 M90 200 L50 360 L180 330 L230 180 M350 230 L300 400 L180 330 M50 360 L180 330 M180 330 L130 500 L280 540 L300 400 L360 470 L280 540" />
</g>
{[
[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) => (
<circle key={i} cx={x} cy={y} r={accent ? 3.4 : 2.2} fill={accent ? '#F59E0B' : '#2a4569'}>
{accent ? <animate attributeName="opacity" values="1;0.4;1" dur="2.6s" begin={`${i * 0.4}s`} repeatCount="indefinite" /> : null}
</circle>
))}
</svg>
<div className="absolute -left-24 top-1/3 h-72 w-72 rounded-full bg-accent/20 blur-[100px]" />
<div className="absolute right-0 bottom-0 h-80 w-80 rounded-full bg-info/10 blur-[110px]" />
<div className="relative flex items-center gap-3">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-accent/20 shadow-glow">
<Activity className="h-6 w-6 text-accent" />
</div>
<div>
<div className="text-lg font-bold tracking-tight text-white">Железный Егорин</div>
<div className="text-xs text-slate-400">Единая смартпанель мониторинга</div>
</div>
</div>
<div className="relative">
<h2 className="max-w-md text-3xl font-bold leading-tight tracking-tight text-white">
Энергетика, транспорт и сеть — <span className="text-accent">в одном пульте</span>
</h2>
<div className="mt-6 grid grid-cols-1 gap-2.5">
{FEATURES.map((f) => (
<FeatureItem key={f.title} f={f} />
))}
</div>
</div>
<div className="relative text-xs text-slate-500">
© <a href="https://servaki.online" className="text-slate-400 hover:text-accent">servaki.online</a> · приём данных Navtelecom FLEX/NTCB · RU-инфраструктура
</div>
</div>
{/* Панель входа — правая половина: odyssey WebGL-молния (тёплая), форма поверх стеклом */}
<div className="relative flex w-full flex-col items-center justify-center overflow-hidden p-6 lg:w-1/2">
<div className="pointer-events-none absolute inset-0 bg-[#0a0602]">
<Lightning hue={35} speed={0.9} intensity={0.95} size={1.1} className="h-full w-full opacity-80" />
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0602] via-[#0a0602]/25 to-[#0a0602]/55" />
</div>
<div className="relative z-10 w-full max-w-sm rounded-2xl border border-white/10 bg-black/45 p-6 shadow-2xl backdrop-blur-md">
<div className="mb-8 lg:hidden">
<div className="flex items-center gap-2.5">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-accent/15 shadow-glow">
<Activity className="h-5 w-5 text-accent" />
</div>
<div className="text-base font-bold tracking-tight">Железный Егорин</div>
</div>
</div>
<h1 className="text-2xl font-bold tracking-tight">Вход в систему</h1>
<p className="mt-1 text-sm text-fg-muted">Авторизуйтесь, чтобы открыть пульт мониторинга.</p>
{errMsg && (
<div className="mt-4 rounded-xl border border-crit/40 bg-crit/10 px-3.5 py-2.5 text-sm text-crit">
{errMsg}
</div>
)}
<button
type="button"
onClick={() => (window.location.href = '/api/auth/yandex/start')}
className="mt-7 flex w-full items-center justify-center gap-2.5 rounded-xl bg-[#FC3F1D] px-4 py-3.5 text-sm font-semibold text-white shadow-sm transition-transform hover:-translate-y-0.5"
>
<span className="grid h-5 w-5 place-items-center rounded bg-white text-[13px] font-bold text-[#FC3F1D]">Я</span>
Войти через Яндекс ID
</button>
<div className="my-5 flex items-center gap-3 text-[11px] uppercase tracking-wide text-fg-muted">
<span className="h-px flex-1 bg-border" />
или
<span className="h-px flex-1 bg-border" />
</div>
<button
type="button"
onClick={loginDemo}
className="group flex w-full items-center justify-between rounded-xl border border-border bg-surface px-4 py-3.5 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
>
<span className="flex items-center gap-3">
<span className="grid h-9 w-9 place-items-center rounded-lg bg-accent/15">
<Eye className="h-4 w-4 text-accent" />
</span>
<span>
<span className="block text-sm font-medium text-fg">Демо-доступ</span>
<span className="block text-xs text-fg-muted">все разделы · только просмотр</span>
</span>
</span>
<ArrowRight className="h-4 w-4 text-fg-muted transition-transform group-hover:translate-x-0.5" />
</button>
<p className="mt-6 text-center text-[11px] text-fg-muted">Доступ к данным — по ролям и арендатору (clientId).</p>
</div>
</div>
</div>
);
}
+26
View File
@@ -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 (
<div className={cn('relative overflow-hidden border border-border', full ? 'fixed inset-0 z-[1500] m-0 rounded-none bg-bg sm:inset-2 sm:rounded-xl' : 'rounded-xl')}>
<button
type="button"
onClick={() => setFull((f) => !f)}
title={full ? 'Свернуть карту' : 'Развернуть на весь экран'}
className="absolute right-2 top-2 z-[1600] grid h-10 w-10 place-items-center rounded-lg border border-border bg-surface/90 text-fg-muted shadow-card backdrop-blur hover:text-fg sm:h-8 sm:w-8"
>
{full ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<div className={cn(full && 'h-full')}>{children(full)}</div>
</div>
);
}
+28
View File
@@ -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 (
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-accent/15">
<Icon className="h-5 w-5 text-accent" />
</div>
<div>
<h1 className="text-lg font-bold tracking-tight">{title}</h1>
{desc && <p className="text-sm text-fg-muted">{desc}</p>}
</div>
</div>
{actions}
</div>
);
}
+70
View File
@@ -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<string, string> = { 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(
<div className="fixed bottom-4 right-4 z-[3500] w-[300px] overflow-hidden rounded-2xl border border-border bg-surface shadow-card">
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
<div className="flex items-center gap-2">
<span className={cn('grid h-7 w-7 place-items-center rounded-lg', on ? 'bg-accent/20 text-accent' : 'bg-muted text-fg-muted')}>
<Radio className="h-4 w-4" />
</span>
<div>
<div className="text-sm font-semibold leading-tight">Рация</div>
<div className="text-[10px] text-fg-muted">{on ? 'оповещение включено' : 'оповещение выключено'}</div>
</div>
</div>
<button onClick={onClose} className="grid h-7 w-7 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg"><X className="h-4 w-4" /></button>
</div>
{/* Вкл/выкл оповещение */}
<div className="flex items-center gap-2 px-3 py-3">
<button
onClick={toggle}
className={cn('flex flex-1 items-center justify-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold transition-colors', on ? 'bg-accent text-[#06210f] hover:opacity-90' : 'border border-border text-fg hover:bg-muted')}
>
{on ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
{on ? 'Выключить оповещение' : 'Включить оповещение'}
</button>
<button onClick={test} title="Проверка связи" className="grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-border text-fg-muted hover:bg-muted hover:text-fg">
<Play className="h-4 w-4" />
</button>
</div>
{/* Эфир */}
<div className="border-t border-border">
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wide text-fg-muted">Эфир · последние события</div>
<div className="max-h-48 space-y-0.5 overflow-y-auto px-2 pb-2">
{ether.length === 0 && <div className="px-2 py-4 text-center text-xs text-fg-muted">Пока тихо. События появятся здесь.</div>}
{ether.map((e, i) => (
<div key={i} className="flex items-start gap-2 rounded-lg px-2 py-1.5 hover:bg-muted/50">
<span className={cn('mt-1 h-2 w-2 shrink-0 rounded-full', DOT[e.severity] ?? 'bg-fg-muted')} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="truncate font-medium text-fg">{e.callsign}</span>
<span className="tnum shrink-0 text-fg-muted">{tm(e.ts)}</span>
</div>
<div className="truncate text-[11px] text-fg-muted">{e.text}</div>
</div>
</div>
))}
</div>
</div>
</div>,
document.body,
);
}
+240
View File
@@ -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 (
<button
type="button"
onClick={toggle}
title={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
className="grid h-9 w-9 place-items-center rounded-xl text-fg-muted hover:bg-muted hover:text-fg"
>
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
);
}
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 (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 rounded-xl py-1 pl-1 pr-2 hover:bg-muted"
title="Сменить пользователя (демо)"
>
<div className="grid h-9 w-9 place-items-center rounded-full bg-secondary text-xs font-semibold">
{initials(user.name)}
</div>
<div className="hidden text-left sm:block">
<div className="text-xs font-semibold leading-tight">{user.name}</div>
<div className="text-[10px] text-fg-muted">{user.role}</div>
</div>
<ChevronDown className="h-4 w-4 text-fg-muted" />
</button>
{open &&
createPortal(
<>
<div className="fixed inset-0 z-[2999]" onClick={() => setOpen(false)} />
<div className="fixed right-3 top-14 z-[3000] w-64 overflow-hidden rounded-xl border border-border bg-surface-2 shadow-card">
<div className="px-3 py-2 text-[11px] uppercase tracking-wide text-fg-muted">Войти как (демо)</div>
{USERS.map((u) => (
<button
key={u.id}
type="button"
onClick={() => {
setUser(u);
setOpen(false);
navigate('/');
}}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-muted"
>
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-secondary text-[11px] font-semibold">
{initials(u.name)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{u.name}</div>
<div className="truncate text-[11px] text-fg-muted">{u.role}</div>
</div>
{u.id === user.id && <Check className="h-4 w-4 text-accent" />}
</button>
))}
<div className="border-t border-border">
<button
type="button"
onClick={() => { setOpen(false); navigate('/cabinet'); }}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm text-fg hover:bg-muted"
>
<Wallet className="h-4 w-4 text-accent" /> Личный кабинет
</button>
<button
type="button"
onClick={() => {
setOpen(false);
logout();
}}
className="flex w-full items-center gap-2 border-t border-border px-3 py-2.5 text-left text-sm text-crit hover:bg-muted"
>
<LogOut className="h-4 w-4" /> Выйти
</button>
</div>
</div>
</>,
document.body,
)}
</div>
);
}
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 (
<div className="flex h-full min-h-dvh bg-bg">
<aside className={cn('flex w-16 flex-col border-r border-border bg-surface/60 py-4', !navCollapsed && 'md:w-60')}>
<button type="button" onClick={() => setConfirmHome(true)} className="flex items-center gap-2.5 rounded-xl px-3 py-1 transition-colors hover:bg-muted md:px-4" title="На главную (обновить панель)">
<div className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent/15 shadow-glow">
<Activity className="h-5 w-5 text-accent" />
</div>
<div className={cn('text-left', lblBlock)}>
<div className="text-sm font-bold leading-tight tracking-tight">Железный Егорин</div>
<div className="text-[11px] text-fg-muted">Смартпанель</div>
</div>
</button>
<nav className="mt-6 flex flex-1 flex-col gap-1 px-2 md:px-3">
{mods.filter((m) => !m.hidden).map((m) => (
<NavLink
key={m.id}
to={m.path}
className={({ isActive }) =>
cn(
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors duration-200',
isActive ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:bg-muted hover:text-fg',
)
}
title={m.title}
>
<m.icon className="h-5 w-5 shrink-0" />
<span className={lbl}>{m.title}</span>
</NavLink>
))}
</nav>
<div className="px-2 md:px-3">
<div className="flex items-center gap-2 rounded-xl px-3 py-2 text-fg-muted">
<StatusDot state="ok" pulse />
<span className={cn('text-xs', lbl)}>Онлайн</span>
</div>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center justify-between gap-3 border-b border-border bg-surface/40 px-4 py-2.5 backdrop-blur md:px-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={toggleNav}
title={navCollapsed ? 'Развернуть меню' : 'Свернуть меню'}
className="grid h-9 w-9 place-items-center rounded-xl text-fg-muted hover:bg-muted hover:text-fg"
>
{navCollapsed ? <PanelLeftOpen className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</button>
<span className="tnum hidden text-xs text-fg-muted sm:inline">
{new Date().toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<button
type="button"
onClick={() => setShowRadio((v) => !v)}
title="Рация — окно оповещения и эфир"
className={cn('relative grid h-9 w-9 place-items-center rounded-xl hover:bg-muted', radioOn ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:text-fg')}
>
<Radio className="h-5 w-5" />
{(ether.length || crit) > 0 && (
<span className={cn('absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full px-1 text-[10px] font-bold text-white', radioOn ? 'bg-accent' : 'bg-crit')}>
{ether.length || crit}
</span>
)}
</button>
<UserSwitcher />
</div>
</header>
<RadioMiniWindow open={showRadio} onClose={() => setShowRadio(false)} />
<main className="min-w-0 flex-1 overflow-y-auto p-4 md:p-6">
<Outlet />
</main>
</div>
{confirmHome && (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setConfirmHome(false)} />
<div className="relative z-10 w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-card">
<h3 className="text-base font-semibold">Вернуться на главную?</h3>
<p className="mt-1 text-sm text-fg-muted">Панель будет обновлена — перезагрузятся разделы и сущности.</p>
<div className="mt-5 flex justify-end gap-2">
<button type="button" onClick={() => setConfirmHome(false)} className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted">
Отмена
</button>
<button type="button" onClick={() => window.location.assign('/')} className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-[#06210f] hover:opacity-90">
На главную
</button>
</div>
</div>
</div>
)}
</div>
);
}
+76
View File
@@ -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 (
<div className={cn('overflow-hidden', className)}>
<button
type="button"
onClick={() =>
setOpen((v) => {
const next = !v;
onToggle?.(next);
return next;
})
}
aria-expanded={open}
className="flex w-full items-center gap-3 py-3 text-left transition-colors hover:bg-muted/40"
>
{left}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-fg">{title}</div>
{subtitle && <div className="truncate text-[11px] text-fg-muted">{subtitle}</div>}
</div>
{right}
<ChevronDown
className={cn('h-4 w-4 shrink-0 text-fg-muted transition-transform duration-200', open && 'rotate-180')}
/>
</button>
<div
className={cn(
'grid transition-all duration-300 ease-out',
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="pb-3">{children}</div>
</div>
</div>
</div>
);
}
/** Таблица «свойство → значение» для содержимого ролл-панели. */
export function PropList({ rows }: { rows: Array<[string, ReactNode]> }) {
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 rounded-lg bg-bg/40 p-3 text-xs">
{rows.map(([k, v], i) => (
<div key={i} className="contents">
<dt className="text-fg-muted">{k}</dt>
<dd className="tnum break-words text-right font-medium text-fg">{v}</dd>
</div>
))}
</dl>
);
}
+60
View File
@@ -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<Tone, string> = {
accent: 'text-accent',
ok: 'text-ok',
warn: 'text-warn',
crit: 'text-crit',
info: 'text-info',
muted: 'text-fg',
};
// Цветной контур плитки по статусу (красный/жёлтый/зелёный…)
const RING: Record<Tone, string> = {
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 (
<Card className={cn('p-4', ring && RING[ring])}>
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-fg-muted">{label}</span>
{Icon && <Icon className={cn('h-4 w-4', TONE[tone])} />}
</div>
<div className="mt-3 flex items-baseline gap-1.5">
<span className={cn('tnum text-2xl font-semibold tracking-tight', TONE[tone])}>
{typeof value === 'number' ? fmt(value, digits) : value}
</span>
{unit && <span className="text-sm text-fg-muted">{unit}</span>}
</div>
{sub && <div className="mt-1.5 text-xs text-fg-muted">{sub}</div>}
</Card>
);
}
+84
View File
@@ -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 (
<div
className={cn(
'rounded-2xl border border-border bg-surface shadow-card',
className,
)}
>
{children}
</div>
);
}
export function CardHeader({
title,
desc,
right,
}: {
title: ReactNode;
desc?: ReactNode;
right?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3 px-4 pt-4">
<div>
<h3 className="text-sm font-semibold tracking-tight text-fg">{title}</h3>
{desc && <p className="mt-0.5 text-xs text-fg-muted">{desc}</p>}
</div>
{right}
</div>
);
}
const SEV_STYLES: Record<Severity, string> = {
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 (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium',
SEV_STYLES[severity],
className,
)}
>
{children}
</span>
);
}
const DOT: Record<Severity | 'offline', string> = {
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 (
<span className="relative inline-flex h-2.5 w-2.5">
{pulse && state !== 'offline' && (
<span className={cn('absolute inline-flex h-full w-full rounded-full opacity-60 animate-pulse-dot', DOT[state])} />
)}
<span className={cn('relative inline-flex h-2.5 w-2.5 rounded-full', DOT[state])} />
</span>
);
}
+54
View File
@@ -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));
+130
View File
@@ -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<Record<SectionId, SectionAccess>>;
}
export const SECTIONS: SectionId[] = ['energy', 'smart', 'gps', 'netinfra', 'security'];
export const SECTION_LABEL: Record<SectionId, string> = {
energy: 'Энергомониторинг',
smart: 'Смартпанель',
gps: 'GPS-мониторинг',
netinfra: 'Сетевая инфраструктура',
security: 'Охранный пульт (ПЦН)',
};
export const CAP_LABEL: Record<Capability, string> = {
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 };
}
+78
View File
@@ -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 };
}
+120
View File
@@ -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<RadioState>((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]);
}
+52
View File
@@ -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<void>;
loginDemo: () => Promise<void>;
setUser: (u: User) => Promise<void>;
logout: () => void;
toggleCap: (userId: string, section: SectionId, cap: Capability) => void;
}
export const useAuth = create<AuthState>((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)) })),
}));
+39
View File
@@ -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<ThemeState>((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 });
},
}));
+37
View File
@@ -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<string, number>; // нормализованные метрики (В, А, Вт, °C, %)
meta?: Record<string, string>; // строковые свойства (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<Severity, number> = {
critical: 3,
warning: 2,
info: 1,
ok: 0,
};
+83
View File
@@ -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<string, string>; // stageKey -> ISO ts
comments: HandlingComment[];
}
const KEY = 'sm-alarm-handling';
function load(): Record<string, Handling> {
try {
return JSON.parse(localStorage.getItem(KEY) || '{}');
} catch {
return {};
}
}
interface HState {
map: Record<string, Handling>;
stamp: (alarmId: string, stage: string, iso: string) => void;
addComment: (alarmId: string, text: string, author: string, iso: string) => void;
}
function persist(map: Record<string, Handling>) {
try {
localStorage.setItem(KEY, JSON.stringify(map));
} catch {
/* quota */
}
}
export const useAlarmHandling = create<HState>((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 };
}),
}));
+84
View File
@@ -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<GeoState>((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 };
}),
}));
+63
View File
@@ -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<GState>((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 };
}),
}));
+89
View File
@@ -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<T extends { clientId: string }>(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<LocState>((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 };
}),
}));
+81
View File
@@ -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),
},
];
+132
View File
@@ -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<string, number | string> = { 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(),
},
];
+148
View File
@@ -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<string, string> = 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<NotifySchedule, string> = {
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<NotifyState>((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 — вызывается только по клику пользователя (не при рендере/резюме).
+78
View File
@@ -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> */
name: string;
category: RepoCategory;
/** default-ветка (main, кроме исключений) */
branch: 'main' | 'master';
private: boolean;
/** пустой репо (заготовка) */
empty?: boolean;
/** недавно добавлен — подсветить */
isNew?: boolean;
note: string;
}
export const CATEGORY_LABEL: Record<RepoCategory, string> = {
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'];
+73
View File
@@ -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;
+203
View File
@@ -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<string, string> = {
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<string, string> = {
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 },
];
+28
View File
@@ -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);
}
+69
View File
@@ -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;
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { User } from '@/core/auth';
export interface ReportSpec {
title: string;
columns: string[];
rows: Array<Array<string | number>>;
note?: string;
}
const esc = (s: string | number): string =>
String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[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) => `<th>${esc(c)}</th>`).join('');
const body = spec.rows
.map((r) => `<tr>${r.map((c) => `<td>${esc(c)}</td>`).join('')}</tr>`)
.join('');
const html = `<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>${esc(spec.title)}</title>
<style>
*{font-family:'Fira Sans',Arial,sans-serif;box-sizing:border-box}
body{margin:32px;color:#0f172a}
h1{font-size:20px;margin:0 0 4px}
.meta{font-size:12px;color:#475569;margin-bottom:18px;border-bottom:2px solid #22c55e;padding-bottom:10px}
.meta b{color:#0f172a}
table{width:100%;border-collapse:collapse;font-size:12px}
th{text-align:left;background:#f1f5f9;color:#334155;padding:8px;border-bottom:2px solid #cbd5e1}
td{padding:7px 8px;border-bottom:1px solid #e2e8f0}
tr:nth-child(even) td{background:#f8fafc}
.note{margin-top:14px;font-size:11px;color:#64748b}
.foot{margin-top:26px;font-size:10px;color:#94a3b8;border-top:1px solid #e2e8f0;padding-top:8px}
@media print{body{margin:14mm}}
</style></head><body>
<h1>${esc(spec.title)}</h1>
<div class="meta">Раздел: <b>${esc(sectionTitle)}</b> &nbsp;·&nbsp; Сформирован: <b>${esc(now)}</b> &nbsp;·&nbsp; Инициатор: <b>${esc(user.name)}</b> (${esc(user.role || '')})</div>
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
${spec.note ? `<p class="note">${esc(spec.note)}</p>` : ''}
<div class="foot">Железный Егорин · Смартпанель · отчёт сформирован автоматически</div>
<script>window.onload=function(){setTimeout(function(){window.print()},300)}</script>
</body></html>`;
const w = window.open('', '_blank', 'width=920,height=720');
if (!w) return; // popup заблокирован — тихо выходим
w.document.write(html);
w.document.close();
}
+17
View File
@@ -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,
});
}
+13
View File
@@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+247
View File
@@ -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<SectionId, ComponentType<{ className?: string }>> = {
energy: Zap,
smart: Cpu,
gps: Navigation,
netinfra: Network,
security: Siren,
};
const SECTION_COMMENT: Record<SectionId, string> = {
energy: 'Электроснабжение: PV / АКБ (SOC) / нагрузка, аварии питания, выработка.',
smart: 'Устройства Wiren Board: реле, диммеры, датчики (вкл/выкл, уровни).',
gps: 'Транспорт и стационарные объекты: позиции, треки, поездки, геозоны.',
netinfra: 'Узлы и каналы сети mesh2: связанность, телеметрия, фото с камер.',
security: 'Охранный пульт (ПЦН): приём тревог Surgard/SIA, ГБР, дозвон, фото.',
};
const CAP_ICON: Record<Capability, ComponentType<{ className?: string }>> = {
view: Eye,
edit: Pencil,
reports: FileText,
};
const CAP_COMMENT: Record<Capability, string> = {
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 (
<button
type="button"
role="checkbox"
aria-checked={on}
disabled={!editable}
onClick={onToggle}
title={title}
className={cn(
'grid h-6 w-6 place-items-center rounded-md border transition-colors',
on ? 'border-accent bg-accent text-bg' : 'border-border text-transparent',
editable ? 'hover:border-accent/70' : 'cursor-default opacity-90',
)}
>
<Check className="h-3.5 w-3.5" />
</button>
);
}
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 (
<td className={cn('border-l border-border px-3 py-3 align-top', SECTION_BG[alt])}>
<div className="grid grid-cols-3 justify-items-center gap-2">
{ALL_CAPS.map((cap) => (
<CapBox
key={cap}
on={!!a?.caps.includes(cap)}
editable={editable}
onToggle={() => onToggle(cap)}
title={`${SECTION_LABEL[section]} · ${CAP_LABEL[cap]} — ${CAP_COMMENT[cap]}`}
/>
))}
</div>
<div className="mt-1.5 text-center">
{a ? (
<span className={cn('text-[10px]', a.objects === 'all' ? 'text-fg-muted' : 'text-warn')}>
{a.objects === 'all' ? 'все объекты' : `${a.objects.length} из набора`}
</span>
) : (
<span className="text-[10px] text-fg-muted/50">нет доступа</span>
)}
</div>
</td>
);
}
export function AccessModule() {
const { user: current, users, setUser, toggleCap } = useAuth();
const isAdmin = !!current.admin;
return (
<div>
<PageHeader
icon={ShieldCheck}
title="Доступ и роли"
desc="Права на разделы И на объекты внутри разделов · уровни: просмотр / правка / отчёты"
actions={<Badge severity={isAdmin ? 'ok' : 'info'}>{isAdmin ? 'админ · правка прав' : 'только просмотр'}</Badge>}
/>
<Card>
<CardHeader
title="Матрица доступа"
desc={isAdmin ? 'строка — пользователь · колонка — раздел · 3 поля: просмотр / правка / отчёты. Наведите на чекбокс — пояснение.' : 'строка — пользователь, колонка — раздел'}
/>
<div className="overflow-x-auto px-4 pb-4">
<table className="w-full min-w-[1040px] border-separate border-spacing-0 text-sm">
<thead>
{/* Ряд 1 — разделы (иконка + название + комментарий) */}
<tr className="text-left text-xs">
<th rowSpan={2} className="sticky left-0 z-10 border-b border-border bg-surface py-2 pr-4 align-bottom font-medium text-fg-muted">
Пользователь
</th>
{SECTIONS.map((s, i) => {
const Icon = SECTION_ICON[s];
return (
<th key={s} className={cn('border-l border-border px-3 pt-3 pb-1 text-center align-bottom', SECTION_BG[i % 2])}>
<div className="flex flex-col items-center gap-1" title={SECTION_COMMENT[s]}>
<Icon className="h-4 w-4 text-accent" />
<span className="font-semibold leading-tight text-fg">{SECTION_LABEL[s]}</span>
<Info className="h-3 w-3 text-fg-muted/60" />
</div>
</th>
);
})}
<th rowSpan={2} className="border-b border-border py-2 pl-3 align-bottom font-medium text-fg-muted" />
</tr>
{/* Ряд 2 — поля прав (просмотр/правка/отчёты) */}
<tr className="text-[10px] uppercase tracking-wide text-fg-muted">
{SECTIONS.map((s, i) => (
<th key={s} className={cn('border-b border-l border-border px-3 pb-2', SECTION_BG[i % 2])}>
<div className="grid grid-cols-3 justify-items-center gap-2">
{ALL_CAPS.map((cap) => {
const CI = CAP_ICON[cap];
return (
<span key={cap} className="flex flex-col items-center gap-0.5" title={CAP_COMMENT[cap]}>
<CI className="h-3 w-3" />
</span>
);
})}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className={cn('border-b border-border', u.id === current.id && 'bg-accent/5')}>
<td className={cn('sticky left-0 z-10 border-b border-border py-3 pr-4 align-top', u.id === current.id ? 'bg-[#0c1626]' : 'bg-surface')}>
<div className="font-medium text-fg">{u.name}</div>
<div className="text-[11px] text-fg-muted">{u.role}</div>
{u.admin && (
<Badge severity="ok" className="mt-1">
админ
</Badge>
)}
</td>
{SECTIONS.map((s, i) => (
<SectionGroup key={s} u={u} section={s} alt={i % 2} editable={isAdmin && !u.admin} onToggle={(cap) => toggleCap(u.id, s, cap)} />
))}
<td className="border-b border-l border-border py-3 pl-3 align-top">
{u.id === current.id ? (
<span className="inline-flex items-center gap-1 text-xs text-accent">
<Check className="h-3.5 w-3.5" /> вы
</span>
) : (
<button
type="button"
onClick={() => setUser(u)}
className="whitespace-nowrap rounded-lg bg-muted px-3 py-1.5 text-xs font-medium text-fg hover:bg-secondary hover:text-white"
>
Войти как
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Легенда — комментарии полей */}
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader title="Поля прав" desc="что означает каждый столбец" />
<div className="space-y-2 px-4 pb-4">
{ALL_CAPS.map((cap) => {
const CI = CAP_ICON[cap];
return (
<div key={cap} className="flex items-start gap-2.5 text-xs">
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-md border border-accent bg-accent/15 text-accent">
<CI className="h-3.5 w-3.5" />
</span>
<div>
<span className="font-medium text-fg">{CAP_LABEL[cap]}</span>
<span className="text-fg-muted"> — {CAP_COMMENT[cap]}</span>
</div>
</div>
);
})}
</div>
</Card>
<Card>
<CardHeader title="Разделы" desc="за что отвечает каждая колонка" />
<div className="space-y-2 px-4 pb-4">
{SECTIONS.map((s) => {
const Icon = SECTION_ICON[s];
return (
<div key={s} className="flex items-start gap-2.5 text-xs">
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-md bg-accent/15 text-accent">
<Icon className="h-3.5 w-3.5" />
</span>
<div>
<span className="font-medium text-fg">{SECTION_LABEL[s]}</span>
<span className="text-fg-muted"> — {SECTION_COMMENT[s]}</span>
</div>
</div>
);
})}
</div>
</Card>
</div>
<div className="mt-4 flex items-start gap-2 rounded-xl border border-border bg-surface/50 p-3 text-xs text-fg-muted">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-info" />
<p>
Модель RBAC: у каждого пользователя — набор разделов с уровнями (просмотр/правка/отчёты) и областью объектов
(все либо часть). Меню, объекты, кнопки правки и выгрузки отчётов фильтруются по правам. Администратор выдаёт
доступ чекбоксами (<span className="font-mono">view</span> — базовый: правка/отчёты требуют просмотр). В проде права
приходят с бэкенда по <span className="font-mono">clientId</span> (мультиарендность).
{!isAdmin && ' Правка прав доступна только администратору.'}
</p>
</div>
</div>
);
}
+277
View File
@@ -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<string, number>;
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 (
<div className="flex flex-wrap items-center gap-2">
{blocked ? (
<>
<Badge severity="critical">
<Ban className="h-3 w-3" /> заблокирован{u.blocked?.until ? ` до ${new Date(u.blocked.until).toLocaleDateString('ru-RU')}` : ''}
</Badge>
<button onClick={unblock} className="inline-flex items-center gap-1 rounded-md bg-accent/15 px-2 py-1 text-[11px] text-accent hover:bg-accent/25">
<Unlock className="h-3 w-3" /> разморозить
</button>
</>
) : (
<>
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} title="блок с даты" className="rounded-md border border-border bg-surface-2 px-1.5 py-1 text-[11px] text-fg" />
<input type="date" value={until} onChange={(e) => setUntil(e.target.value)} title="блок до даты (разморозка с даты)" className="rounded-md border border-border bg-surface-2 px-1.5 py-1 text-[11px] text-fg" />
<button onClick={block} disabled={u.admin} className="inline-flex items-center gap-1 rounded-md bg-crit/15 px-2 py-1 text-[11px] text-crit hover:bg-crit/25 disabled:opacity-40">
<Ban className="h-3 w-3" /> блок
</button>
{until && (
<button onClick={unblockFrom} className="text-[11px] text-fg-muted hover:text-fg" title="заблокировать сейчас, разморозить автоматически в дату «до»">
⏱ до даты
</button>
)}
</>
)}
</div>
);
}
export function AdminModule() {
const [stats, setStats] = useState<Stats | null>(null);
const [online, setOnline] = useState<OnlineUser[]>([]);
const [activity, setActivity] = useState<Act[]>([]);
const [users, setUsers] = useState<AUser[]>([]);
const [nEmail, setNEmail] = useState('');
const [nName, setNName] = useState('');
const [traffic, setTraffic] = useState<Record<string, number[]>>({ gps: [], pcn: [], gw: [] });
const tickTraffic = () =>
setTraffic((prev) => {
const base: Record<string, number> = { gps: 120, pcn: 40, gw: 90 };
const next: Record<string, number[]> = {};
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<Stats>('/admin/stats').then(setStats).catch(() => {});
apiGet<OnlineUser[]>('/admin/online').then(setOnline).catch(() => {});
apiGet<Act[]>('/admin/activity').then((a) => setActivity(a.slice(0, 40))).catch(() => {});
tickTraffic();
};
const loadUsers = () => apiGet<AUser[]>('/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 (
<div>
<PageHeader
icon={ShieldAlert}
title="Админ-консоль"
desc="Нагрузка · онлайн-пользователи · журнал · управление доступом"
actions={<Badge severity="ok">live · обновление 5с</Badge>}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<KpiCard label="Онлайн" value={stats?.onlineTotal ?? 0} digits={0} icon={Users} tone="accent" />
<KpiCard label="Запросов/мин" value={stats?.reqPerMin ?? 0} digits={0} icon={Activity} tone="info" />
<KpiCard label="RAM шлюза" value={stats?.rssMb ?? 0} unit="МБ" digits={0} icon={Cpu} tone={stats && stats.rssMb > 400 ? 'warn' : 'muted'} />
<KpiCard label="Аптайм" value={stats ? upt(stats.uptimeSec) : '—'} icon={Clock} tone="muted" />
<KpiCard label="Всего запросов" value={stats?.reqTotal ?? 0} digits={0} icon={Gauge} tone="muted" />
</div>
{/* Трафик от ингрисов — детект атак (всплеск = возможная атака) */}
<Card className="mt-4">
<CardHeader title="Трафик от ингрисов" desc="пакетов/мин по приёмным нодам · всплеск = возможная атака" right={<Badge severity="ok"><Activity className="h-3 w-3" /> live</Badge>} />
<div className="grid grid-cols-1 gap-3 px-4 pb-4 md:grid-cols-3">
{[
{ 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 (
<div key={n.id} className={cn('rounded-xl border p-3', status === 'crit' ? 'border-crit/60 ring-1 ring-crit/40' : status === 'warn' ? 'border-warn/50' : 'border-border')}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-fg">{n.name}</span>
<Badge severity={status === 'crit' ? 'critical' : status === 'warn' ? 'warning' : 'ok'}>{label}</Badge>
</div>
<div className="mt-0.5 font-mono text-[10px] text-fg-muted">{n.node}</div>
<div className="mt-2 flex items-end gap-0.5" style={{ height: 34 }}>
{hist.map((v, i) => (
<div key={i} className={cn('flex-1 rounded-sm', v >= 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)) }} />
))}
</div>
<div className="mt-1 flex items-baseline gap-1">
<span className={cn('tnum text-xl font-semibold', status === 'crit' ? 'text-crit' : status === 'warn' ? 'text-warn' : 'text-fg')}>{cur}</span>
<span className="text-[11px] text-fg-muted">пакетов/мин</span>
</div>
</div>
);
})}
</div>
<p className="px-4 pb-3 text-[11px] text-fg-muted">Демо-метрики. В проде — реальный счётчик сообщений шлюза по префиксам топиков (navtelecom/ = GPS, guard/ = ПЦН) + порог авто-тревоги.</p>
</Card>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader title="Онлайн по разделам" desc={`${online.length} пользователей сейчас`} />
<div className="flex flex-wrap gap-2 px-4 pb-3">
{Object.entries(stats?.bySection || {}).map(([s, n]) => (
<Badge key={s} severity="info">
{s}: {n}
</Badge>
))}
{!Object.keys(stats?.bySection || {}).length && <span className="text-sm text-fg-muted">нет онлайн</span>}
</div>
<div className="divide-y divide-border px-4 pb-3">
{online.map((o) => (
<div key={o.id} className="flex items-center gap-3 py-2 text-sm">
<span className="grid h-7 w-7 place-items-center rounded-full bg-secondary text-[10px] font-semibold">{o.name.slice(0, 2).toUpperCase()}</span>
<span className="flex-1 truncate">{o.name}</span>
<Badge severity="info">{o.section}</Badge>
<span className="tnum text-xs text-fg-muted">{o.secAgo}с назад</span>
</div>
))}
</div>
</Card>
<Card>
<CardHeader title="Журнал активности" desc="последние действия · эфир" />
<div className="max-h-80 divide-y divide-border overflow-y-auto px-4 pb-3">
{activity.map((a, i) => (
<div key={i} className="flex items-center gap-2 py-1.5 text-xs">
<Clock className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="tnum text-fg-muted">{dt(a.ts)}</span>
<span className="min-w-0 flex-1 truncate text-fg">{a.name}</span>
<Badge severity="info">{a.section}</Badge>
</div>
))}
</div>
</Card>
</div>
<Card className="mt-4">
<CardHeader title="Пользователи и блокировки" desc={`${users.length} учётных записей`} />
<div className="px-4 pb-4">
<div className="mb-3 flex flex-col gap-2 sm:flex-row">
<input value={nEmail} onChange={(e) => 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" />
<input value={nName} onChange={(e) => 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" />
<button onClick={addUser} className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-[#06210f] hover:opacity-90">
<UserPlus className="h-4 w-4" /> Добавить
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-fg-muted">
<th className="py-2 pr-3 font-medium">Пользователь</th>
<th className="py-2 pr-3 font-medium">Арендатор</th>
<th className="py-2 pr-3 font-medium">Разделы</th>
<th className="py-2 pr-3 font-medium">Блокировка</th>
<th className="py-2 font-medium" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map((u) => (
<tr key={u.id} className={cn(u.blocked && 'bg-crit/5')}>
<td className="py-2.5 pr-3">
<div className="font-medium text-fg">{u.name}</div>
<div className="text-[11px] text-fg-muted">{u.email || u.id} {u.admin && '· админ'}</div>
</td>
<td className="py-2.5 pr-3 font-mono text-[11px] text-fg-muted">{u.clientId}</td>
<td className="py-2.5 pr-3 text-[11px] text-fg-muted">{u.sections.join(', ') || '—'}</td>
<td className="py-2.5 pr-3">
<BlockRow u={u} onChange={loadUsers} />
</td>
<td className="py-2.5">
{!u.admin && (
<button onClick={() => delUser(u.id)} className="grid h-7 w-7 place-items-center rounded-md text-fg-muted hover:bg-muted hover:text-crit" title="Удалить">
<Trash2 className="h-4 w-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Card>
</div>
);
}
+165
View File
@@ -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<Billing | null>(null);
const [amount, setAmount] = useState(500);
const [busy, setBusy] = useState(false);
const [links, setLinks] = useState<string[]>([]);
const [newEmail, setNewEmail] = useState('');
useEffect(() => {
apiGet<Billing>('/billing').then(setB).catch(() => {});
apiGet<string[]>('/cabinet/links').then(setLinks).catch(() => {});
}, []);
const addLink = async () => {
const e = newEmail.trim();
if (!e) return;
const r = await apiPost<string[]>('/cabinet/links', { email: e });
setLinks(r || []);
setNewEmail('');
};
const removeLink = async (email: string) => {
const r = await apiDelete<string[]>(`/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 (
<div>
<PageHeader icon={Wallet} title="Личный кабинет" desc="Баланс · тариф · пополнение доступа" actions={<Badge severity="info">кабинет: {user.clientId}</Badge>} />
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<KpiCard label="Баланс" value={b?.balance ?? 0} unit="₽" digits={0} icon={Wallet} tone="accent" />
<KpiCard label="Тариф" value={b?.plan ?? '—'} icon={Check} tone="ok" />
<KpiCard label="Устройств" value={b?.devices ?? 0} digits={0} icon={Smartphone} tone="muted" />
<KpiCard label="Аккаунт" value={user.name} icon={CreditCard} tone="muted" />
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader title="Пополнить баланс" desc="выберите сумму и способ оплаты" />
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{AMOUNTS.map((a) => (
<button
key={a}
onClick={() => setAmount(a)}
className={cn('rounded-lg border px-4 py-2 text-sm font-medium transition-colors', amount === a ? 'border-accent bg-accent/15 text-accent' : 'border-border text-fg hover:bg-muted')}
>
{fmt(a, 0)} ₽
</button>
))}
<input
type="number"
value={amount}
onChange={(e) => 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"
/>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<button
onClick={() => topup('yoomoney')}
disabled={busy || amount <= 0}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[#8B3FFD] px-4 py-3 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
<CreditCard className="h-4 w-4" /> ЮMoney / Яндекс
</button>
<button
onClick={() => topup('sberbank')}
disabled={busy || amount <= 0}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[#21A038] px-4 py-3 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
<Building2 className="h-4 w-4" /> Сбербанк Онлайн
</button>
</div>
<p className="mt-3 text-xs text-fg-muted">⚠️ Демо-заглушка: реальное списание не происходит, открывается тестовая форма оплаты. Боевая интеграция ЮMoney/Сбербанк — по плану.</p>
</div>
</Card>
<Card>
<CardHeader title="Доступ и подписка" desc="что входит в тариф" />
<div className="space-y-2 px-4 pb-4 text-sm">
{['GPS-мониторинг своего автопарка', 'Привязка трекеров по IMEI', 'Треки, поездки, геозоны', 'Отчёты CSV/PDF', 'Уведомления и «Рация»'].map((f) => (
<div key={f} className="flex items-center gap-2 text-fg">
<Check className="h-4 w-4 shrink-0 text-ok" /> {f}
</div>
))}
<div className="mt-3 rounded-lg bg-bg/40 p-3 text-xs text-fg-muted">
Тариф <span className="font-medium text-fg">{b?.plan ?? '—'}</span>. Расширенные модули (Энерго / Смарт / Сеть) подключает администратор.
</div>
</div>
</Card>
</div>
{/* Связанные аккаунты — общий кабинет для нескольких Яндекс-входов */}
<Card className="mt-4">
<CardHeader
title={<span className="flex items-center gap-1.5"><Users className="h-4 w-4 text-accent" /> Связанные аккаунты</span>}
desc="добавьте e-mail коллеги — при входе через Яндекс он попадёт в этот же кабинет (общие объекты и устройства)"
right={<Badge severity="info">{links.length}</Badge>}
/>
<div className="px-4 pb-4">
<div className="mb-3 flex flex-col gap-2 sm:flex-row">
<input
value={newEmail}
onChange={(e) => 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"
/>
<button onClick={addLink} disabled={!newEmail.trim()} className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-[#06210f] hover:opacity-90 disabled:opacity-40">
<UserPlus className="h-4 w-4" /> Привязать
</button>
</div>
{links.length === 0 ? (
<p className="py-3 text-center text-sm text-fg-muted">Связанных аккаунтов нет. Кабинет доступен только вам ({user.name}).</p>
) : (
<div className="divide-y divide-border">
{links.map((e) => (
<div key={e} className="flex items-center gap-2 py-2 text-sm">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-secondary text-[10px] font-semibold">{e.slice(0, 2).toUpperCase()}</span>
<span className="min-w-0 flex-1 truncate text-fg">{e}</span>
<Badge severity="ok">кабинет: {user.clientId}</Badge>
<button onClick={() => removeLink(e)} className="grid h-7 w-7 place-items-center rounded-md text-fg-muted hover:bg-muted hover:text-crit" title="Отвязать">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
<p className="mt-2 text-[11px] text-fg-muted">Связанный аккаунт видит те же объекты, устройства и данные, но не является администратором. Отвязка — кнопкой корзины.</p>
</div>
</Card>
</div>
);
}
+131
View File
@@ -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<Device[]>([]);
const [imei, setImei] = useState('');
const [name, setName] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const refresh = () => apiGet<Device[]>('/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 (
<div>
<PageHeader
icon={Smartphone}
title="Мои устройства"
desc="Привязка трекеров Navtelecom по IMEI к вашему кабинету"
actions={<Badge severity="info">кабинет: {user.clientId}</Badge>}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader title="Привязанные трекеры" desc={`${devices.length} устройств(а)`} />
<div className="px-4 pb-4">
<div className="flex flex-col gap-2 sm:flex-row">
<input
value={imei}
onChange={(e) => 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"
/>
<input
value={name}
onChange={(e) => 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"
/>
<button
onClick={bind}
disabled={busy}
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-[#06210f] hover:opacity-90 disabled:opacity-50"
>
<Plus className="h-4 w-4" /> Привязать
</button>
</div>
{err && <p className="mt-1.5 text-xs text-crit">{err}</p>}
<div className="mt-3 divide-y divide-border">
{devices.length === 0 ? (
<p className="py-7 text-center text-sm text-fg-muted">
Устройств пока нет. Привяжите трекер по IMEI — данные появятся после первого сеанса связи.
</p>
) : (
devices.map((d) => (
<div key={d.imei} className="flex items-center gap-3 py-3">
<div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-accent/15 text-accent">
<Smartphone className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-fg">{d.name}</div>
<div className="font-mono text-[11px] text-fg-muted">IMEI {d.imei}</div>
</div>
<button onClick={() => remove(d.imei)} className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-crit" title="Отвязать">
<Trash2 className="h-4 w-4" />
</button>
</div>
))
)}
</div>
</div>
</Card>
<Card className="lg:col-span-1">
<CardHeader title="Как подключить трекер" />
<div className="space-y-2.5 px-4 pb-4 text-sm text-fg-muted">
<p className="flex items-start gap-2">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-info" /> В конфигураторе Navtelecom укажите телематический сервер:
</p>
<div className="rounded-lg bg-bg/40 p-3 font-mono text-xs leading-relaxed text-fg">
домен: gps.servaki.online
<br />
порт: 2000
<br />
протокол: FLEX
</div>
<p>
После привязки IMEI входящие данные попадут в ваш кабинет (<span className="font-mono">{user.clientId}</span>) и появятся в GPS-мониторинге — только вам.
</p>
</div>
</Card>
</div>
</div>
);
}
+224
View File
@@ -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 (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs shadow-card">
<div className="mb-1 font-medium text-fg">{label}</div>
{payload.map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2 tnum">
<span className="inline-block h-2 w-2 rounded-full" style={{ background: p.color }} />
<span className="text-fg-muted">{p.name}:</span>
<span className="text-fg">{fmt(p.value, 0)}</span>
</div>
))}
</div>
);
}
// Пиктограмма аварий: клик раскрывает контекст; «Аварий нет» в сером поле при отсутствии
function AlertsStrip({ alerts }: { alerts: MonitorEvent[] }) {
const [openId, setOpenId] = useState<string | null>(null);
if (!alerts.length) {
return (
<div className="mt-4 flex items-center justify-center gap-2 rounded-2xl border border-border bg-muted/40 px-4 py-7 text-sm text-fg-muted">
<ShieldCheck className="h-5 w-5 text-ok" /> Аварий нет
</div>
);
}
return (
<Card className="mt-4">
<CardHeader title="Аварии" desc={`активных: ${alerts.length} · нажмите для подробностей`} right={<Badge severity="critical">{alerts.length}</Badge>} />
<div className="space-y-2 px-4 pb-4">
{alerts.map((a) => (
<div key={a.id} className="overflow-hidden rounded-lg border border-border">
<button type="button" onClick={() => setOpenId((id) => (id === a.id ? null : a.id))} className="flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/40">
<span className={`grid h-8 w-8 shrink-0 place-items-center rounded-lg ${a.severity === 'critical' ? 'bg-crit/15' : 'bg-warn/15'}`}>
<TriangleAlert className={`h-4 w-4 ${a.severity === 'critical' ? 'text-crit' : 'text-warn'}`} />
</span>
<span className="min-w-0 flex-1 truncate text-sm text-fg">{a.text}</span>
<Badge severity={a.severity}>{a.severity === 'critical' ? 'критично' : a.severity}</Badge>
<ChevronDown className={`h-4 w-4 shrink-0 text-fg-muted transition-transform ${openId === a.id ? 'rotate-180' : ''}`} />
</button>
<div className={`grid transition-all duration-300 ${openId === a.id ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}>
<div className="overflow-hidden">
<div className="px-3 pb-3">
<PropList
rows={[
['Объект', a.callsign],
['Тип события', a.type],
['Сообщение', a.text],
['Время', new Date(a.ts).toLocaleString('ru-RU')],
['Квитирование', a.ackRequired ? 'требуется' : 'не требуется'],
]}
/>
</div>
</div>
</div>
</div>
))}
</div>
</Card>
);
}
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 (
<div>
<PageHeader
icon={Zap}
title="Энергомониторинг"
desc="Sunways STH → Wiren Board → MQTT/InfluxDB · щитовые шкафы Navtelecom (FLEX)"
actions={
<div className="flex items-center gap-2">
{!canEdit && <Badge severity="info">только просмотр</Badge>}
{canReports && (
<ExportPdfButton
sectionTitle={SECTION_LABEL.energy}
build={() => ({
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.`,
})}
/>
)}
<Badge severity="ok">демо-моки</Badge>
</div>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
<KpiCard label="Мощность PV" value={energyKpi.pvPower} unit="кВт" icon={Sun} tone="accent" />
<KpiCard label="Заряд АКБ" value={energyKpi.soc} unit="%" digits={0} icon={BatteryCharging} tone="ok" sub="режим: разряд" />
<KpiCard label="Напряж. АКБ" value={energyKpi.battU} unit="В" icon={Gauge} tone="muted" />
<KpiCard label="Ток АКБ" value={energyKpi.battI} unit="А" icon={Gauge} tone={energyKpi.battI < 0 ? 'warn' : 'ok'} />
<KpiCard label="Темп. BMS" value={energyKpi.bmsTemp} unit="°C" icon={Thermometer} tone="muted" />
<KpiCard label="Выработка/сут" value={energyKpi.pvToday} unit="кВт·ч" icon={Sun} tone="info" />
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardHeader title="Генерация PV по объектам" desc="за сутки, Вт · линия на каждый объект" right={<Badge severity="info">24 ч</Badge>} />
<div className="h-72 px-2 pb-2 pt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={energyByObject} margin={{ top: 4, right: 12, left: -8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#27344B" vertical={false} />
<XAxis dataKey="t" tick={{ fill: '#94A3B8', fontSize: 11 }} stroke="#27344B" interval={3} />
<YAxis tick={{ fill: '#94A3B8', fontSize: 11 }} stroke="#27344B" width={44} />
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{energyObjects.map((o) => (
<Line key={o.key} type="monotone" dataKey={o.key} name={o.name} stroke={o.color} strokeWidth={2} dot={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
</Card>
<Card>
<CardHeader title="Заряд АКБ (SOC) по объектам" desc="за сутки, %" />
<div className="h-72 px-2 pb-2 pt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={energyByObject} margin={{ top: 4, right: 12, left: -8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#27344B" vertical={false} />
<XAxis dataKey="t" tick={{ fill: '#94A3B8', fontSize: 11 }} stroke="#27344B" interval={5} />
<YAxis domain={[0, 100]} tick={{ fill: '#94A3B8', fontSize: 11 }} stroke="#27344B" width={36} />
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{energyObjects.map((o) => (
<Line key={o.key} type="monotone" dataKey={`${o.key}_soc`} name={o.name.replace('Инвертор ', 'АКБ ')} stroke={o.color} strokeWidth={2} dot={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
</Card>
</div>
<AlertsStrip alerts={powerAlerts} />
{/* Ролл-панели подробных свойств */}
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card className="px-4">
<Collapsible
defaultOpen
title="Инвертор Sunways STH — подробно"
subtitle="Modbus RTU · slave 247 · 9600 8N1"
left={<Sun className="h-4 w-4 shrink-0 text-accent" />}
>
<PropList
rows={[
['PV1 / PV2', `${fmt(2.1)} / ${fmt(1.74)} кВт`],
['Суммарно PV', `${fmt(energyKpi.pvPower)} кВт`],
['Напряжение АКБ', `${fmt(energyKpi.battU)} В`],
['Ток АКБ', `${fmt(energyKpi.battI)} А (разряд)`],
['Заряд (SOC)', `${energyKpi.soc} %`],
['Температура BMS', `${fmt(energyKpi.bmsTemp)} °C`],
['Режим', 'Гибридный · разряд АКБ'],
['Выработка за сутки', `${fmt(energyKpi.pvToday)} кВт·ч`],
]}
/>
</Collapsible>
</Card>
<Card className="px-4">
<Collapsible
title="Тракт сбора данных"
subtitle="Sunways → Wiren Board → InfluxDB → Grafana"
left={<Gauge className="h-4 w-4 shrink-0 text-info" />}
>
<PropList
rows={[
['Контроллер', 'Wiren Board (wb-mqtt-serial)'],
['Брокер MQTT', 'mosquitto :1883'],
['Сбор метрик', 'Telegraf → InfluxDB 2.7'],
['Хранение', 'raw 30 дн · 1ч-агрегаты'],
['Дашборды', 'Grafana 11'],
['Алерты', 'Telegram @Svetdo_bot'],
['Глубина архива', '6 ч … 1 год'],
['Статус', 'демо (моки) · стек на Proxmox — этап развёртывания'],
]}
/>
</Collapsible>
</Card>
</div>
</div>
);
}
+151
View File
@@ -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<Geofence>(zone);
return (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-2xl border border-border bg-surface shadow-card">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2"><MapPinned className="h-4 w-4 text-accent" /><span className="text-sm font-semibold">Геозона</span></div>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-3 p-4">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Название</label>
<input value={z.name} onChange={(e) => 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" />
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Широта</label>
<input value={z.lat} onChange={(e) => 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" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Долгота</label>
<input value={z.lon} onChange={(e) => 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" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Радиус, м</label>
<input value={z.radius} onChange={(e) => 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" />
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Цвет</label>
<div className="flex gap-2">
{ZONE_PALETTE.map((c) => (
<button key={c} type="button" onClick={() => setZ({ ...z, color: c })} className={cn('h-7 w-7 rounded-full border-2', z.color === c ? 'scale-110 border-fg' : 'border-border')} style={{ backgroundColor: c }} />
))}
</div>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-border px-4 py-3">
<button onClick={onClose} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-fg hover:bg-muted">Отмена</button>
<button onClick={() => { upsert(z); onClose(); }} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-xs font-semibold text-[#06210f] hover:opacity-90"><Check className="h-3.5 w-3.5" /> Сохранить</button>
</div>
</div>
</div>
);
}
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<Geofence | null>(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 (
<div>
<PageHeader
icon={MapPinned}
title="Геозоны"
desc="Круговые зоны на карте · вход/выход объектов · триггер уведомлений"
actions={
<div className="flex items-center gap-2">
<Badge severity="info">локально · демо</Badge>
{canEdit && (
<button onClick={() => setEdit(emptyZone())} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-[#06210f] hover:opacity-90"><Plus className="h-4 w-4" /> Зона</button>
)}
</div>
}
/>
<GpsTabs />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader title="Зоны" desc={`${zones.length} · объектов на карте: ${objs.length}`} />
<div className="space-y-2 px-3 pb-3">
{zones.map((z) => {
const inside = inZone(z);
return (
<div key={z.id} className={cn('rounded-lg border border-border p-2.5', !z.enabled && 'opacity-60')}>
<div className="flex items-center gap-2">
<button role="switch" aria-checked={z.enabled} onClick={() => toggle(z.id)} className={cn('relative h-5 w-9 shrink-0 rounded-full transition-colors', z.enabled ? 'bg-accent' : 'bg-muted')}>
<span className={cn('absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all', z.enabled ? 'left-4' : 'left-0.5')} />
</button>
<Circle className="h-3.5 w-3.5 shrink-0" style={{ color: z.color, fill: z.color }} />
<button onClick={() => setSel(z.id)} className="min-w-0 flex-1 truncate text-left text-sm font-medium text-fg">{z.name}</button>
{canEdit && (
<>
<button onClick={() => setEdit(z)} className="grid h-7 w-7 place-items-center rounded-md text-fg-muted hover:bg-muted hover:text-fg"><Pencil className="h-3.5 w-3.5" /></button>
<button onClick={() => remove(z.id)} className="grid h-7 w-7 place-items-center rounded-md text-fg-muted hover:bg-muted hover:text-crit"><Trash2 className="h-3.5 w-3.5" /></button>
</>
)}
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2 pl-11 text-[11px] text-fg-muted">
<span className="tnum">R {z.radius} м · {z.lat.toFixed(3)}, {z.lon.toFixed(3)}</span>
<Badge severity={inside.length ? 'ok' : 'info'}>в зоне: {inside.length}</Badge>
</div>
{!!inside.length && (
<div className="mt-1 pl-11 text-[11px] text-fg">{inside.map((o) => o.name).join(', ')}</div>
)}
</div>
);
})}
{!zones.length && <div className="px-2 py-6 text-center text-sm text-fg-muted">Зон нет. Добавьте зону.</div>}
</div>
</Card>
<Card className="lg:col-span-2">
<CardHeader title="Карта геозон" desc="объекты + активные зоны" />
<div className="m-4">
<MapFrame>
{(full) => (
<MapView
entities={objs}
selectedId={sel}
followId={null}
onSelect={setSel}
circles={circles}
className={full ? 'h-full w-full' : 'h-64 w-full sm:h-80 lg:h-[28rem]'}
/>
)}
</MapFrame>
</div>
</Card>
</div>
{edit && <ZoneEditor zone={edit} onClose={() => setEdit(null)} />}
</div>
);
}
+95
View File
@@ -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<string, string> = { critical: 'bg-crit', warning: 'bg-warn', info: 'bg-info', ok: 'bg-ok' };
export function EventsModule() {
const [sev, setSev] = useState<Severity | 'all'>('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 (
<div>
<PageHeader icon={History} title="История событий" desc="Единый журнал событий по объектам · охрана, транспорт, энергетика" />
<GpsTabs />
<Card className="p-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="flex gap-1">
{SEV.map((s) => (
<button key={s.key} onClick={() => setSev(s.key)} className={cn('rounded-lg px-2.5 py-1.5 text-xs font-medium', sev === s.key ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:bg-muted')}>{s.label}</button>
))}
</div>
<div className="relative ml-auto">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
<input value={q} onChange={(e) => 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" />
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-fg-muted">
<th className="py-2 pr-3 font-medium">Время</th>
<th className="py-2 pr-3 font-medium">Объект</th>
<th className="py-2 pr-3 font-medium">Раздел</th>
<th className="py-2 pr-3 font-medium">Тип</th>
<th className="py-2 font-medium">Событие</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((r, i) => (
<tr key={i} className="hover:bg-muted/40">
<td className="whitespace-nowrap py-2.5 pr-3 text-xs text-fg-muted"><Clock className="mr-1 inline h-3 w-3" />{dt(r.ts)}</td>
<td className="py-2.5 pr-3 font-medium text-fg">{r.callsign}</td>
<td className="py-2.5 pr-3"><Badge severity="info">{r.section}</Badge></td>
<td className="py-2.5 pr-3 font-mono text-[11px] text-fg-muted">{r.type}</td>
<td className="py-2.5">
<div className="flex items-center gap-2">
<span className={cn('h-2 w-2 shrink-0 rounded-full', DOT[r.severity])} />
<span className="text-fg">{r.text}</span>
</div>
</td>
</tr>
))}
{!filtered.length && <tr><td colSpan={5} className="py-8 text-center text-sm text-fg-muted">Событий не найдено</td></tr>}
</tbody>
</table>
</div>
<p className="mt-2 text-[11px] text-fg-muted">Демо-журнал (моки + сработки ПЦН). В проде — единый EventBus per-арендатор с фильтрами и экспортом.</p>
</Card>
</div>
);
}
+556
View File
@@ -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<string, string> = { transport: 'Транспорт', energy: 'Щит/энерго', realty: 'Объект' };
const TELE_LABEL: Record<string, [string, string]> = {
speed: ['Скорость', 'км/ч'],
sat: ['Спутники', ''],
fuel: ['Топливо', '%'],
voltage: ['Бортсеть', 'В'],
pwr_ext: ['Сеть внешн.', 'В'],
pwr_int: ['АКБ', 'В'],
temp: ['Температура', '°C'],
};
function Metric({ icon: Icon, value, unit }: { icon: any; value: number; unit: string }) {
return (
<span className="inline-flex items-center gap-1 text-xs text-fg-muted">
<Icon className="h-3.5 w-3.5" />
<span className="tnum text-fg">{fmt(value, 0)}</span>
{unit}
</span>
);
}
function FollowBox({ on, onClick }: { on: boolean; onClick: () => void }) {
return (
<button
type="button"
role="checkbox"
aria-checked={on}
onClick={onClick}
title="Следить за объектом на карте"
className={cn(
'grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-colors',
on ? 'border-accent bg-accent text-bg' : 'border-border text-transparent hover:border-fg-muted',
)}
>
<Check className="h-3.5 w-3.5" />
</button>
);
}
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 (
<div className="py-1">
<div className="flex items-center gap-2 py-2" onContextMenu={onContext}>
<FollowBox on={follow} onClick={onFollow} />
<button
type="button"
role="checkbox"
aria-checked={!mapHidden}
onClick={onToggleVisible}
title={mapHidden ? 'Показать на карте' : 'Скрыть с карты'}
className={cn('grid h-5 w-5 shrink-0 place-items-center rounded-md transition-colors', mapHidden ? 'text-fg-muted hover:text-fg' : 'text-accent')}
>
{mapHidden ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<StatusDot state={e.online ? 'ok' : 'offline'} pulse={e.online} />
<button
type="button"
onClick={() => {
onSelect();
onToggleOpen();
}}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span className="truncate text-sm font-medium text-fg">{e.name}</span>
{addr && <span className="shrink-0 truncate text-[10px] text-fg-muted">· {addr}</span>}
</div>
<div className="truncate text-[11px] text-fg-muted">{TYPE_LABEL[e.type]}{mapHidden && ' · скрыт'}</div>
</div>
{e.online ? <Wifi className="h-4 w-4 text-ok" /> : <WifiOff className="h-4 w-4 text-fg-muted" />}
<ChevronDown className={cn('h-4 w-4 text-fg-muted transition-transform', open && 'rotate-180')} />
</button>
{e.lat != null && e.lon != null && <ShareCoords name={e.name} address={address} lat={e.lat} lon={e.lon} />}
</div>
<div className={cn('grid transition-all duration-300 ease-out', open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<div className="overflow-hidden">
<div className="pb-2 pl-7">
<PropList
rows={[
['ID объекта', <span className="font-mono">{e.id}</span>],
['Клиент', 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],
),
]}
/>
<div className="mt-2 flex flex-wrap gap-2">
<button
type="button"
onClick={onSelect}
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"
>
<Crosshair className="h-3.5 w-3.5" /> Показать на карте
</button>
{canEdit && (
<button
type="button"
onClick={onEdit}
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-fg hover:bg-muted"
>
<Pencil className="h-3.5 w-3.5" /> Редактировать
</button>
)}
<button
type="button"
role="checkbox"
aria-checked={pcnVisible}
onClick={onTogglePcn}
title="Показывать этот ГЛОНАСС-трекер (ГБР) на карте охранного пульта (ПЦН)"
className={cn(
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
pcnVisible ? 'border-info bg-info/15 text-info' : 'border-border text-fg-muted hover:bg-muted',
)}
>
<ShieldCheck className="h-3.5 w-3.5" /> {pcnVisible ? 'видим для ПЦН ✓' : 'видим для ПЦН'}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
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<string | null>(null);
const [followId, setFollowId] = useState<string | null>(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<string | null>(null);
const [tripId, setTripId] = useState<string | null>(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 (
<div>
<PageHeader icon={Navigation} title="GPS-мониторинг" desc="Navtelecom FLEX/NTCB · Traccar" />
<Card className="p-8 text-center text-sm text-fg-muted">Нет доступных объектов для вашей роли.</Card>
</div>
);
}
return (
<div>
<PageHeader
icon={Navigation}
title="GPS-мониторинг"
desc="Navtelecom FLEX/NTCB · Traccar REST/WS · автопарк и стационарные объекты"
actions={
<div className="flex items-center gap-2">
{!canEdit && <Badge severity="info">только просмотр</Badge>}
{canReports && (
<ExportPdfButton
sectionTitle={SECTION_LABEL.gps}
build={() => ({
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}.`,
})}
/>
)}
<Badge severity="ok">
{objs.filter((e) => e.online).length}/{objs.length} онлайн
</Badge>
<Badge severity={live ? 'ok' : 'info'}>{live ? 'live · шлюз' : 'демо'}</Badge>
</div>
}
/>
<GpsTabs />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader title="Объекты" desc="☑ — слежение на карте" />
<div className="divide-y divide-border px-3 pb-2">
{objs.map((e) => (
<ObjectRow
key={e.id}
e={e}
icon={pictogramFor(e.type, overrides[e.id]?.icon)}
open={openId === e.id}
follow={followId === e.id}
canEdit={canEdit}
pcnVisible={!!overrides[e.id]?.pcnVisible}
mapHidden={!!overrides[e.id]?.mapHidden}
address={overrides[e.id]?.address || e.meta?.address}
onToggleVisible={() => 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 })}
/>
))}
</div>
</Card>
<Card className="lg:col-span-2">
<CardHeader
title={cur.name}
desc={followId === cur.id ? 'слежение включено · карта следует за объектом' : `обновлено ${new Date(cur.updatedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`}
right={
<div className="flex items-center gap-2">
{!!overrides[cur.id]?.pcnVisible && <Badge severity="info"><ShieldCheck className="h-3 w-3" /> ПЦН</Badge>}
{followId === cur.id && <Badge severity="ok">слежение</Badge>}
<Badge severity={cur.online ? 'ok' : 'critical'}>{cur.online ? 'на связи' : 'нет связи'}</Badge>
{canEdit && (
<button
type="button"
onClick={() => setEditId(cur.id)}
title="Редактировать объект"
className="grid h-7 w-7 place-items-center rounded-lg border border-border text-fg-muted hover:bg-muted hover:text-fg"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
}
/>
<div className="m-4">
<MapFrame>
{(full) => (
<MapSwitch
entities={objs.filter((e) => !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]'}
/>
)}
</MapFrame>
</div>
<div className="flex flex-wrap gap-4 px-4 pb-4">
{'speed' in cur.telemetry && <Metric icon={Gauge} value={cur.telemetry.speed} unit=" км/ч" />}
{'sat' in cur.telemetry && <Metric icon={Satellite} value={cur.telemetry.sat} unit=" спутн." />}
{'fuel' in cur.telemetry && <Metric icon={Fuel} value={cur.telemetry.fuel} unit="% топл." />}
{'voltage' in cur.telemetry && <Metric icon={Gauge} value={cur.telemetry.voltage} unit=" В" />}
</div>
</Card>
</div>
{/* Плеер трека по выбранной поездке */}
{trip && (
<div className="mt-4">
<TrackPlayer
trip={trip}
color={trackColors[sel] ?? '#22C55E'}
onCursor={setCursor}
onClose={() => {
setTripId(null);
setCursor(null);
}}
/>
</div>
)}
{/* Дорожные события на треке (детектор поездок/стоянок) */}
{roadEvents.length > 0 && (
<Card className="mt-4">
<CardHeader
title="Дорожные события на треке"
desc={`${cur.name} · ${trip ? 'выбранная поездка' : 'все поездки'} · старт / стоп / стоянка / превышение`}
right={
<button
type="button"
onClick={() => 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 ? 'на карте ✓' : 'показать на карте'}
</button>
}
/>
<div className="flex flex-wrap gap-1.5 px-4 pb-4">
{roadEvents.map((e) => {
const k = ROAD_KIND[e.kind];
return (
<span key={e.id} className="inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[11px] text-fg" title={e.sub}>
<span className="grid h-4 w-4 place-items-center rounded-full text-[9px] font-bold text-white" style={{ background: k.color }}>{k.char}</span>
{e.label}
<span className="tnum text-fg-muted">{new Date(e.ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}</span>
</span>
);
})}
</div>
</Card>
)}
{/* Журнал перемещений (поездок) */}
<Card className="mt-4">
<CardHeader
title="Журнал перемещений — поездки"
desc={`${cur.name} · ${curTrips.length} поездок · клик по строке — маршрут на карте`}
right={
canReports ? (
<button
type="button"
onClick={() => 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"
>
<FileDown className="h-3.5 w-3.5" /> Отчёт CSV
</button>
) : (
<Badge severity="info">отчёты недоступны</Badge>
)
}
/>
<div className="overflow-x-auto px-4 pb-4">
{curTrips.length === 0 ? (
<p className="py-6 text-center text-sm text-fg-muted">Поездок нет</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-fg-muted">
<th className="py-2 pr-3 font-medium">Время</th>
<th className="py-2 pr-3 font-medium">Маршрут</th>
<th className="py-2 pr-3 text-right font-medium">Дист.</th>
<th className="py-2 pr-3 text-right font-medium">Время в пути</th>
<th className="py-2 text-right font-medium">Макс/средн</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{curTrips.map((t) => (
<tr
key={t.id}
onClick={() => setTripId(tripId === t.id ? null : t.id)}
className={cn('cursor-pointer text-fg hover:bg-muted/40', tripId === t.id && 'bg-accent/10')}
>
<td className="py-2.5 pr-3">
<div className="flex items-center gap-1.5 text-xs text-fg-muted">
<Clock className="h-3.5 w-3.5" />
{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' })}
</div>
</td>
<td className="py-2.5 pr-3">
<div className="flex items-center gap-1.5">
<RouteIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="text-xs">
{t.from} → {t.to}
</span>
</div>
</td>
<td className="tnum py-2.5 pr-3 text-right">{fmt(t.distanceKm)} км</td>
<td className="tnum py-2.5 pr-3 text-right">{t.durationMin} мин</td>
<td className="tnum py-2.5 text-right text-xs text-fg-muted">
{t.maxSpeed}/{t.avgSpeed} км/ч
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Card>
{ctx && (
<ContextMenu
x={ctx.x}
y={ctx.y}
onClose={() => 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) && (
<ObjectEditModal entity={objs.find((e) => e.id === editId)!} onClose={() => setEditId(null)} />
)}
{modal && objs.find((e) => e.id === modal.id) && (
<ObjectModal
entity={objs.find((e) => e.id === modal.id)!}
tab={modal.tab}
onTab={(t) => setModal((m) => (m ? { ...m, tab: t } : m))}
onClose={() => setModal(null)}
/>
)}
</div>
);
}
+33
View File
@@ -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 (
<div className="mb-4 flex flex-wrap gap-1.5 border-b border-border pb-3">
{TABS.map((t) => (
<NavLink
key={t.to}
to={t.to}
end
className={({ isActive }) =>
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.icon className="h-4 w-4" />
{t.label}
</NavLink>
))}
</div>
);
}
+17
View File
@@ -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<typeof MapView>) {
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 ? <MapViewGL {...(props as any)} /> : <MapView {...props} />;
}
+270
View File
@@ -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<string, L.TileLayer> {
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, string>): 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<string, string>, glyphs?: Record<string, string>) {
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: `<div style="display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:${c};border:2px solid #0F172A;${ring}">${glyph}</div>`,
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: `<div style="width:14px;height:14px;border-radius:50%;background:${c};border:2px solid #0F172A;${ring}"></div>`,
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<string, Array<[number, number]>>;
trackIds?: string[];
trackColors?: Record<string, string>;
links?: Array<{ id: string; from: [number, number]; to: [number, number]; style: 'solid' | 'dash' | 'flow'; color: string }>;
colors?: Record<string, string>; // переопределение цвета маркера по id объекта (ПЦН: тревога/охрана)
glyphs?: Record<string, string>; // 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<HTMLDivElement | null>(null);
const mapRef = useRef<L.Map | null>(null);
const markersRef = useRef<Record<string, L.Marker>>({});
const linesRef = useRef<Record<string, L.Polyline>>({});
const linksRef = useRef<Record<string, L.Polyline>>({});
const cursorRef = useRef<L.Marker | null>(null);
const circlesRef = useRef<Record<string, L.Circle>>({});
const eventsRef = useRef<L.Marker[]>([]);
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(`<div style="font-weight:600">${e.name}</div><div style="opacity:.7;font-size:12px">${e.online ? 'на связи' : 'нет связи'}</div>`, { 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: `<div style="width:18px;height:18px;border-radius:50%;background:#22C55E;border:3px solid #fff;box-shadow:0 0 0 4px #22C55E55,0 1px 4px rgba(0,0,0,.5)"></div>`,
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: `<div style="display:grid;place-items:center;width:18px;height:18px;border-radius:50%;background:${e.color};border:2px solid #0F172A;color:#fff;font:700 11px system-ui;box-shadow:0 1px 3px rgba(0,0,0,.5)">${e.char}</div>`,
iconSize: [18, 18],
iconAnchor: [9, 9],
popupAnchor: [0, -12],
});
const m = L.marker([e.lat, e.lon], { icon, zIndexOffset: 500 }).addTo(map);
m.bindPopup(`<div style="font-weight:600">${e.label}</div>${e.sub ? `<div style="opacity:.7;font-size:12px">${e.sub}</div>` : ''}`, { maxWidth: 220 });
eventsRef.current.push(m);
});
}, [events]);
useEffect(() => {
const onResize = () => mapRef.current?.invalidateSize();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return <div ref={elRef} className={className} />;
}
+232
View File
@@ -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<string, Array<LngLat>>;
trackIds?: string[];
trackColors?: Record<string, string>;
links?: Array<{ id: string; from: LngLat; to: LngLat; style: 'solid' | 'dash' | 'flow'; color: string }>;
colors?: Record<string, string>;
glyphs?: Record<string, string>;
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, string>): 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<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const propsRef = useRef(props);
propsRef.current = props;
const readyRef = useRef(false);
const fittedRef = useRef(false);
const popupRef = useRef<maplibregl.Popup | null>(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(`<div style="font-weight:600">${pr.name}</div><div style="opacity:.7;font-size:12px">${pr.online ? 'на связи' : 'нет связи'}</div>`)
.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(`<div style="font-weight:600">${pr.label}</div>${pr.sub ? `<div style="opacity:.7;font-size:12px">${pr.sub}</div>` : ''}`).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 <div ref={elRef} className={props.className} style={{ position: 'relative' }} />;
}
+169
View File
@@ -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<string, string> = { 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 (
<>
<div className="fixed inset-0 z-[2999]" onClick={onClose} onContextMenu={(e) => { e.preventDefault(); onClose(); }} />
<div
className="fixed z-[3000] w-56 overflow-hidden rounded-xl border border-border bg-surface-2 py-1 shadow-card"
style={{ left: Math.min(x, window.innerWidth - 240), top: Math.min(y, window.innerHeight - 120) }}
>
<button type="button" onClick={onStats} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-fg hover:bg-muted">
<BarChart3 className="h-4 w-4 text-accent" /> Подробная статистика
</button>
<button type="button" onClick={onProps} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-fg hover:bg-muted">
<ListTree className="h-4 w-4 text-info" /> Свойства объекта
</button>
{onEdit && (
<button type="button" onClick={onEdit} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-fg hover:bg-muted">
<Pencil className="h-4 w-4 text-accent" /> Редактировать объект
</button>
)}
</div>
</>
);
}
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-xl border border-border bg-bg/40 p-3">
<div className="text-[11px] uppercase tracking-wide text-fg-muted">{label}</div>
<div className="tnum mt-1 text-xl font-semibold text-fg">{value}</div>
{sub && <div className="text-[11px] text-fg-muted">{sub}</div>}
</div>
);
}
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 (
<div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Stat label="Поездок" value={`${trips.length}`} sub="за период" />
<Stat label="Пробег" value={`${fmt(dist)} км`} sub="суммарно" />
<Stat label="В пути" value={`${Math.floor(dur / 60)} ч ${dur % 60} мин`} />
<Stat label="Макс. скорость" value={`${maxV} км/ч`} />
<Stat label="Средняя" value={`${avgV} км/ч`} />
<Stat label="Статус" value={e.online ? 'на связи' : 'офлайн'} sub={`обновлено ${new Date(e.updatedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`} />
</div>
{trips.length > 0 && (
<div className="mt-4">
<div className="mb-2 text-xs font-medium text-fg-muted">Последние перемещения</div>
<div className="divide-y divide-border">
{trips.map((t) => (
<div key={t.id} className="flex items-center gap-2 py-2 text-xs">
<Clock className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="tnum text-fg-muted">
{new Date(t.start).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</span>
<RouteIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="min-w-0 flex-1 truncate text-fg">
{t.from} → {t.to}
</span>
<span className="tnum shrink-0 text-fg-muted">{fmt(t.distanceKm)} км</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
function PropsView({ e }: { e: MonitoredEntity }) {
return (
<PropList
rows={[
['ID объекта', <span className="font-mono">{e.id}</span>],
['Клиент', 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 (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 max-h-[85vh] w-full max-w-lg overflow-hidden rounded-2xl border border-border bg-surface shadow-card">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-accent" />
<div>
<div className="text-sm font-semibold">{entity.name}</div>
<div className="text-[11px] text-fg-muted">{TYPE_LABEL[entity.type] ?? entity.type}</div>
</div>
</div>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex gap-1 border-b border-border px-3 pt-2">
{(['stats', 'props'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => onTab(t)}
className={cn(
'rounded-t-lg px-3 py-2 text-sm font-medium',
tab === t ? 'border-b-2 border-accent text-accent' : 'text-fg-muted hover:text-fg',
)}
>
{t === 'stats' ? 'Статистика' : 'Свойства'}
</button>
))}
</div>
<div className="max-h-[60vh] overflow-y-auto p-4">{tab === 'stats' ? <StatsView e={entity} /> : <PropsView e={entity} />}</div>
<div className="flex items-center justify-between border-t border-border px-4 py-2.5 text-[11px] text-fg-muted">
<span className="inline-flex items-center gap-1">
<Gauge className="h-3 w-3" /> {entity.online ? 'на связи' : 'офлайн'}
</span>
<Badge severity={entity.online ? 'ok' : 'critical'}>{entity.clientId}</Badge>
</div>
</div>
</div>
);
}
+166
View File
@@ -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<string | undefined>(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 (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 max-h-[88vh] w-full max-w-md overflow-y-auto rounded-2xl border border-border bg-surface shadow-card">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<Pencil className="h-4 w-4 text-accent" />
<div className="text-sm font-semibold">Редактирование объекта</div>
</div>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg">
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 p-4">
{/* Имя */}
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Название</label>
<input
value={name}
onChange={(e) => 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"
/>
<p className="mt-1 text-[11px] text-fg-muted">ID объекта <span className="font-mono">{entity.id}</span> · переименование локальное (отображение)</p>
</div>
{/* Пиктограмма */}
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Пиктограмма</label>
<div className="grid grid-cols-6 gap-2">
<button
type="button"
onClick={() => setIcon(undefined)}
title="По типу объекта (авто)"
className={cn('grid h-10 place-items-center rounded-lg border text-[10px]', !icon ? 'border-accent bg-accent/15 text-accent' : 'border-border text-fg-muted hover:bg-muted')}
>
авто
</button>
{PICTOGRAMS.map((p) => {
const I = p.icon;
return (
<button
key={p.key}
type="button"
onClick={() => setIcon(p.key)}
title={p.label}
className={cn('grid h-10 place-items-center rounded-lg border', icon === p.key ? 'border-accent bg-accent/15 text-accent' : 'border-border text-fg hover:bg-muted')}
>
<I className="h-5 w-5" />
</button>
);
})}
</div>
</div>
{/* Координаты */}
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-fg-muted">
<MapPin className="h-3.5 w-3.5" /> Координаты (широта, долгота)
</label>
<div className="flex gap-2">
<input
value={lat}
onChange={(e) => 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"
/>
<input
value={lon}
onChange={(e) => 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"
/>
</div>
<p className="mt-1 text-[11px] text-fg-muted">ручная установка позиции (стационарные объекты/щиты) · маркер переедет на карте. Пусто — позиция от трекера.</p>
</div>
{/* Цвет трека */}
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-fg-muted">
<Palette className="h-3.5 w-3.5" /> Цвет трека
</label>
<div className="flex flex-wrap items-center gap-2">
{TRACK_PALETTE.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
title={c}
className={cn('h-7 w-7 rounded-full border-2 transition-transform', color.toLowerCase() === c.toLowerCase() ? 'scale-110 border-fg' : 'border-border')}
style={{ backgroundColor: c }}
/>
))}
<label className="ml-1 inline-flex h-7 cursor-pointer items-center gap-1 rounded-lg border border-border px-2 text-[11px] text-fg-muted hover:bg-muted">
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="h-4 w-4 cursor-pointer border-0 bg-transparent p-0" />
свой
</label>
</div>
</div>
{/* Предпросмотр */}
<div className="flex items-center gap-3 rounded-lg border border-border bg-bg/40 px-3 py-2.5">
{(() => {
const I = pictogramFor(entity.type, icon);
return <I className="h-5 w-5 text-fg" />;
})()}
<span className="flex-1 truncate text-sm font-medium text-fg">{name || entity.name}</span>
<span className="h-1.5 w-12 rounded-full" style={{ backgroundColor: color }} />
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border px-4 py-3">
<button type="button" onClick={reset} className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium text-fg-muted hover:bg-muted hover:text-fg">
<RotateCcw className="h-3.5 w-3.5" /> Сбросить
</button>
<div className="flex gap-2">
<button type="button" onClick={onClose} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-fg hover:bg-muted">
Отмена
</button>
<button type="button" onClick={save} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-xs font-semibold text-[#06210f] hover:opacity-90">
<Check className="h-3.5 w-3.5" /> Сохранить
</button>
</div>
</div>
</div>
</div>
);
}
+49
View File
@@ -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 (
<div className="relative">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
title="Отправить координаты в мессенджер"
className={cn('grid h-6 w-6 place-items-center rounded-md text-fg-muted hover:bg-muted hover:text-accent', open && 'bg-muted text-accent')}
>
<Share2 className="h-3.5 w-3.5" />
</button>
{open && (
<>
<div className="fixed inset-0 z-[2999]" onClick={(e) => { e.stopPropagation(); setOpen(false); }} />
<div className="absolute right-0 top-7 z-[3000] w-44 overflow-hidden rounded-xl border border-border bg-surface-2 py-1 shadow-card" onClick={(e) => e.stopPropagation()}>
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wide text-fg-muted">координаты в…</div>
<button onClick={() => { tg(); setOpen(false); }} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-fg hover:bg-muted"><Send className="h-4 w-4 text-info" /> Telegram</button>
<button onClick={() => { wa(); setOpen(false); }} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-fg hover:bg-muted"><MessageCircle className="h-4 w-4 text-ok" /> WhatsApp</button>
<button onClick={() => { copy(); }} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-fg hover:bg-muted"><MessageSquare className="h-4 w-4 text-accent" /> МАКС {copied ? <Check className="ml-auto h-3.5 w-3.5 text-ok" /> : <span className="ml-auto text-[10px] text-fg-muted">копир.</span>}</button>
<button onClick={() => { copy(); }} className="flex w-full items-center gap-2.5 border-t border-border px-3 py-2 text-left text-sm text-fg hover:bg-muted">{copied ? <Check className="h-4 w-4 text-ok" /> : <Copy className="h-4 w-4 text-fg-muted" />} Копировать</button>
</div>
</>
)}
</div>
);
}
+179
View File
@@ -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<ReturnType<typeof setInterval> | 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 (
<div className="rounded-2xl border border-border bg-surface p-4 shadow-card">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<RouteIcon className="h-4 w-4 text-accent" />
<div>
<div className="text-sm font-semibold text-fg">Плеер трека</div>
<div className="text-[11px] text-fg-muted">{trip.from} → {trip.to}</div>
</div>
</div>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={reset} title="В начало" className="grid h-9 w-9 place-items-center rounded-lg border border-border text-fg hover:bg-muted">
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setPlaying((p) => (idx >= N ? (setIdx(0), true) : !p))}
className="grid h-10 w-10 place-items-center rounded-full bg-accent text-[#06210f] hover:opacity-90"
>
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</button>
<input
type="range"
min={0}
max={N}
value={idx}
onChange={(e) => {
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 }}
/>
<div className="flex gap-1">
{[1, 2, 4].map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
className={cn('rounded-md px-2 py-1 text-xs font-medium', speed === s ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:bg-muted')}
>
{s}×
</button>
))}
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<Readout icon={Clock} label="Время" value={tm(curTime)} />
<Readout icon={Gauge} label="Скорость" value={`${curSpeed} км/ч`} />
<Readout icon={RouteIcon} label="Пройдено" value={`${fmt(doneKm)} км`} />
<Readout icon={MapPin} label="Прогресс" value={`${Math.round(progress * 100)}%`} />
</div>
</div>
);
}
function Readout({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
return (
<div className="rounded-lg border border-border bg-bg/40 px-3 py-2">
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wide text-fg-muted">
<Icon className="h-3 w-3" /> {label}
</div>
<div className="tnum mt-0.5 text-sm font-semibold text-fg">{value}</div>
</div>
);
}
+89
View File
@@ -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<string, ComponentType<{ className?: string }>> = Object.fromEntries(PICTOGRAMS.map((p) => [p.key, p.icon]));
// Иконка по умолчанию для типа объекта (если пользователь не выбрал свою)
const DEFAULT_BY_TYPE: Record<string, ComponentType<{ className?: string }>> = {
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<string, ObjectOverride> {
try {
return JSON.parse(localStorage.getItem(KEY) || '{}');
} catch {
return {};
}
}
interface CustomState {
overrides: Record<string, ObjectOverride>;
set: (id: string, patch: ObjectOverride) => void;
reset: (id: string) => void;
}
export const useObjectCustom = create<CustomState>((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 };
}),
}));
+83
View File
@@ -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<string, Array<[number, number]>>;
// Живые данные GPS из шлюза (REST снапшот + WS позиции → треки).
// Фолбэк: встроенное демо с локальной симуляцией движения.
export function useGpsData(user: User): { objs: MonitoredEntity[]; tracks: Tracks; live: boolean } {
const [objs, setObjs] = useState<MonitoredEntity[]>([]);
const [tracks, setTracks] = useState<Tracks>({});
const [live, setLive] = useState(false);
useEffect(() => {
let stop = false;
let ws: WebSocket | undefined;
let sim: ReturnType<typeof setInterval> | 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<string, number> = {};
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<MonitoredEntity[]>('/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 };
}
+181
View File
@@ -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 = `<svg xmlns='http://www.w3.org/2000/svg' width='420' height='230'>
<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#16223C'/><stop offset='1' stop-color='#0F172A'/></linearGradient></defs>
<rect width='420' height='230' fill='url(#g)'/>
<rect x='10' y='10' width='400' height='210' fill='none' stroke='#27344B'/>
<circle cx='210' cy='112' r='30' fill='none' stroke='#22C55E' stroke-width='2'/>
<circle cx='210' cy='112' r='11' fill='#22C55E' opacity='0.55'/>
<text x='20' y='34' fill='#94A3B8' font-family='monospace' font-size='13'>${kind}</text>
<text x='20' y='205' fill='#22C55E' font-family='monospace' font-size='12'>${n.name}</text>
<text x='300' y='205' fill='#94A3B8' font-family='monospace' font-size='12'>06:00 · сегодня</text>
</svg>`;
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<Set<string>>(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 (
<div>
<PageHeader icon={Network} title="Сетевая инфраструктура" desc="mesh2" />
<Card className="p-8 text-center text-sm text-fg-muted">Нет доступных узлов для вашей роли.</Card>
</div>
);
}
return (
<div>
<PageHeader
icon={Network}
title="Сетевая инфраструктура"
desc="mesh2 · Тэмпельхоф · ☑ связанность · линия/пунктир/поток = состояние линка"
actions={
<div className="flex items-center gap-2">
<Badge severity="ok">
{nodes.filter((n) => n.online).length}/{nodes.length} онлайн
</Badge>
{canReports && (
<ExportPdfButton
sectionTitle={SECTION_LABEL.netinfra}
build={() => ({
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) : '—']),
})}
/>
)}
</div>
}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader title="Узлы mesh2" desc="☑ — показать связь с хабом" />
<div className="divide-y divide-border px-3 pb-2">
{nodes.map((n) => {
const on = linkOn.has(n.id);
const ls = linkStyle(n);
return (
<div key={n.id} className="flex items-start gap-2">
<button
type="button"
role="checkbox"
aria-checked={on}
title="Показать связанность на карте"
onClick={() =>
setLinkOn((s) => {
const x = new Set(s);
x.has(n.id) ? x.delete(n.id) : x.add(n.id);
return x;
})
}
className={cn('mt-3.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-colors', on ? 'border-transparent text-bg' : 'border-border text-transparent hover:border-fg-muted')}
style={on ? { background: ls.color } : undefined}
>
<Share2 className="h-3 w-3" />
</button>
<Collapsible
className="flex-1"
onToggle={(o) => o && setSel(n.id)}
left={<StatusDot state={n.online ? 'ok' : 'offline'} pulse={n.online} />}
title={n.name}
subtitle={n.meta?.role}
right={n.online ? <span className="tnum text-[11px] text-fg-muted">{fmt(n.telemetry.latency)} мс</span> : <WifiOff className="h-4 w-4 text-fg-muted" />}
>
<PropList
rows={[
['IP-адрес', <span className="font-mono">{n.meta?.ip}</span>],
['Модель', 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 ? 'онлайн' : 'нет связи'],
]}
/>
<div className="mt-3">
<div className="mb-1 flex items-center gap-1.5 text-[11px] text-fg-muted">
<Camera className="h-3.5 w-3.5" /> {photoOf(n).kind} · обновление 3×/сутки
</div>
<img
src={photoOf(n).url}
alt={photoOf(n).kind}
loading="lazy"
className="h-32 w-full rounded-lg border border-border object-cover"
/>
</div>
</Collapsible>
</div>
);
})}
</div>
</Card>
<Card className="lg:col-span-2">
<CardHeader title={cur.name} desc={cur.meta?.ip} right={<Badge severity={cur.online ? 'ok' : 'critical'}>{cur.online ? 'онлайн' : 'нет связи'}</Badge>} />
<div className="m-4">
<MapFrame>
{(full) => (
<MapView entities={nodes} selectedId={sel} followId={null} onSelect={setSel} links={links} className={full ? 'h-full w-full' : 'h-64 w-full sm:h-80 lg:h-[24rem]'} />
)}
</MapFrame>
</div>
<div className="flex flex-wrap gap-4 px-4 pb-4">
{cur.online && (
<>
<span className="inline-flex items-center gap-1 text-xs text-fg-muted"><Gauge className="h-3.5 w-3.5" /> <span className="tnum text-fg">{fmt(cur.telemetry.latency)}</span> мс</span>
<span className="inline-flex items-center gap-1 text-xs text-fg-muted"><Activity className="h-3.5 w-3.5" /> <span className="tnum text-fg">{fmt(cur.telemetry.uplink, 0)}</span> Мбит/с</span>
<span className="inline-flex items-center gap-1 text-xs text-fg-muted"><Users className="h-3.5 w-3.5" /> <span className="tnum text-fg">{fmt(cur.telemetry.clients, 0)}</span> клиентов</span>
<span className="inline-flex items-center gap-1 text-xs text-fg-muted"><Wifi className="h-3.5 w-3.5" /> {cur.meta?.uplink}</span>
</>
)}
</div>
</Card>
</div>
</div>
);
}
+208
View File
@@ -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<Severity, 'critical' | 'warning' | 'info'> = { critical: 'critical', warning: 'warning', info: 'info', ok: 'info' } as any;
function Chip({ on, onClick, children }: { on: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={cn('inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors', on ? 'border-accent bg-accent/15 text-accent' : 'border-border text-fg-muted hover:bg-muted')}
>
{children}
</button>
);
}
function RuleEditor({ rule, onClose }: { rule: NotifyRule; onClose: () => void }) {
const upsert = useNotify((s) => s.upsert);
const [r, setR] = useState<NotifyRule>(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 (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 max-h-[88vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-border bg-surface shadow-card">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2"><BellRing className="h-4 w-4 text-accent" /><span className="text-sm font-semibold">Правило уведомления</span></div>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-4 p-4">
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Название</label>
<input value={r.name} onChange={(e) => 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" />
</div>
<div>
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-fg-muted">Разделы (триггер) · пусто = любой</div>
<div className="flex flex-wrap gap-2">
{SECTIONS.map((s) => (
<Chip key={s} on={r.sections.includes(s)} onClick={() => toggleIn('sections', s)}>{SECTION_LABEL[s as SectionId]}</Chip>
))}
</div>
</div>
<div>
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-fg-muted">Важность · пусто = любая</div>
<div className="flex flex-wrap gap-2">
{SEVERITIES.map((s) => (
<Chip key={s.key} on={r.severities.includes(s.key)} onClick={() => toggleIn('severities', s.key)}>{s.label}</Chip>
))}
</div>
</div>
<div>
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-fg-muted">Каналы (действия)</div>
<div className="flex flex-wrap gap-2">
{CHANNELS.map((c) => {
const I = c.icon;
return (
<Chip key={c.key} on={r.channels.includes(c.key)} onClick={() => toggleIn('channels', c.key)}>
<I className="h-3.5 w-3.5" /> {c.label}
</Chip>
);
})}
</div>
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Адресаты (телефоны / @ / e-mail / ГБР)</label>
<input value={r.recipients} onChange={(e) => 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" />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Эскалация, мин</label>
<input type="number" min={0} value={r.escalateAfterMin} onChange={(e) => 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" />
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Приоритет</label>
<select value={r.priority} onChange={(e) => setR({ ...r, priority: Number(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">
{[1, 2, 3, 4, 5].map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-fg-muted">Расписание</label>
<select value={r.schedule} onChange={(e) => setR({ ...r, schedule: e.target.value as NotifySchedule })} className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent">
{Object.entries(SCHEDULE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
<button onClick={onClose} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-fg hover:bg-muted">Отмена</button>
<button onClick={() => { upsert(r); onClose(); }} disabled={!r.channels.length} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-xs font-semibold text-[#06210f] hover:opacity-90 disabled:opacity-40">
<Check className="h-3.5 w-3.5" /> Сохранить
</button>
</div>
</div>
</div>
);
}
export function NotifyModule() {
const { rules, remove, toggle } = useNotify();
const [edit, setEdit] = useState<NotifyRule | null>(null);
return (
<div>
<PageHeader
icon={BellRing}
title="Уведомления"
desc="Гибкие сценарии оповещения: событие → каналы (звонок, все мессенджеры вплоть до МАКС, e-mail/SMS/push, ГБР), приоритет, эскалация, расписание"
actions={
<div className="flex items-center gap-2">
<Badge severity="info">локально · демо</Badge>
<button onClick={() => setEdit(emptyRule())} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-[#06210f] hover:opacity-90">
<Plus className="h-4 w-4" /> Правило
</button>
</div>
}
/>
<NotifyTester />
<div className="grid grid-cols-1 gap-3">
{rules.map((r) => (
<Card key={r.id} className={cn('p-4', !r.enabled && 'opacity-60')}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={r.enabled}
onClick={() => toggle(r.id)}
className={cn('relative h-5 w-9 shrink-0 rounded-full transition-colors', r.enabled ? 'bg-accent' : 'bg-muted')}
>
<span className={cn('absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all', r.enabled ? 'left-4' : 'left-0.5')} />
</button>
<span className="truncate text-sm font-semibold text-fg">{r.name}</span>
<Badge severity={r.priority >= 4 ? 'critical' : r.priority >= 3 ? 'warning' : 'info'}>P{r.priority}</Badge>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="inline-flex items-center gap-1 text-fg-muted"><Layers className="h-3 w-3" /></span>
{(r.sections.length ? r.sections : ['любой раздел']).map((s) => (
<Badge key={s} severity="info">{SECTION_LABEL[s as SectionId] ?? s}</Badge>
))}
{(r.severities.length ? r.severities : ['любая важность']).map((s) => (
<Badge key={s} severity={SEV_SEV[s as Severity] ?? 'info'}>{s}</Badge>
))}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{r.channels.map((k) => {
const c = CHANNELS.find((x) => x.key === k);
if (!c) return null;
const I = c.icon;
return (
<span key={k} className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11px] text-fg">
<I className={cn('h-3.5 w-3.5', c.tone)} /> {c.label}
</span>
);
})}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-fg-muted">
{r.recipients && <span>адресаты: <span className="text-fg">{r.recipients}</span></span>}
<span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" /> {SCHEDULE_LABEL[r.schedule]}</span>
{r.escalateAfterMin > 0 && <span className="inline-flex items-center gap-1"><ArrowUpRight className="h-3 w-3" /> эскалация через {r.escalateAfterMin} мин</span>}
</div>
</div>
<div className="flex gap-1">
<button onClick={() => setEdit(r)} className="grid h-8 w-8 place-items-center rounded-lg border border-border text-fg-muted hover:bg-muted hover:text-fg" title="Редактировать"><Pencil className="h-4 w-4" /></button>
<button onClick={() => remove(r.id)} className="grid h-8 w-8 place-items-center rounded-lg border border-border text-fg-muted hover:bg-muted hover:text-crit" title="Удалить"><Trash2 className="h-4 w-4" /></button>
</div>
</div>
</Card>
))}
{!rules.length && <Card className="p-8 text-center text-sm text-fg-muted">Правил нет. Добавьте первое — «Правило».</Card>}
</div>
<div className="mt-4 rounded-xl border border-border bg-surface/50 p-3 text-xs text-fg-muted">
Правило срабатывает, когда событие попадает под раздел И важность (пустой список = «любой»). Действия выполняются по каналам; при отсутствии реакции в течение «эскалации» — повтор/следующий адресат. Сейчас правила хранятся локально (демо); в проде выполняются на шлюзе (EventBus) per-арендатор, с реальной доставкой в мессенджеры (Telegram/WhatsApp/МАКС), звонком и вызовом ГБР.
</div>
{edit && <RuleEditor rule={edit} onClose={() => setEdit(null)} />}
</div>
);
}
+121
View File
@@ -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<SectionId>('security');
const [severity, setSeverity] = useState<Severity>('critical');
const [obj, setObj] = useState('Магазин «ЭлектроКМВ»');
const [showMiss, setShowMiss] = useState(false);
const [log, setLog] = useState<Fired[]>([]);
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 (
<Card className="mb-4">
<CardHeader
title="Тестер правил (движок событие → действия)"
desc="вбросьте событие — движок покажет сработавшие правила, обоснование и каналы; можно выполнить"
right={<Badge severity="info"><FlaskConical className="h-3 w-3" /> dry-run + выполнение</Badge>}
/>
<div className="px-4 pb-4">
<div className="flex flex-wrap items-end gap-2">
<label className="text-[11px] text-fg-muted">
<div className="mb-1 uppercase tracking-wide">Раздел</div>
<select value={section} onChange={(e) => setSection(e.target.value as SectionId)} className="rounded-lg border border-border bg-surface-2 px-2.5 py-2 text-sm text-fg outline-none focus:border-accent">
{SECTIONS.map((s) => <option key={s} value={s}>{SECTION_LABEL[s as SectionId]}</option>)}
</select>
</label>
<label className="text-[11px] text-fg-muted">
<div className="mb-1 uppercase tracking-wide">Важность</div>
<select value={severity} onChange={(e) => setSeverity(e.target.value as Severity)} className="rounded-lg border border-border bg-surface-2 px-2.5 py-2 text-sm text-fg outline-none focus:border-accent">
{SEVERITIES.map((s) => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
</label>
<label className="flex-1 text-[11px] text-fg-muted">
<div className="mb-1 uppercase tracking-wide">Объект (опц.)</div>
<input value={obj} onChange={(e) => setObj(e.target.value)} className="w-full rounded-lg border border-border bg-surface-2 px-2.5 py-2 text-sm text-fg outline-none focus:border-accent" />
</label>
<button onClick={fire} disabled={!res.channels.length} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-[#06210f] hover:opacity-90 disabled:opacity-40">
<Play className="h-4 w-4" /> Проверить и выполнить
</button>
</div>
{/* Результат */}
<div className="mt-3 rounded-xl border border-border bg-bg/40 p-3">
<div className="flex flex-wrap items-center gap-2">
<Zap className={cn('h-4 w-4', res.channels.length ? 'text-accent' : 'text-fg-muted')} />
<span className="text-sm font-semibold text-fg">
{res.matched.length ? `Сработало правил: ${res.matched.length}` : 'Ни одно правило не сработало'}
</span>
{res.channels.map((k) => (
<span key={k} className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/10 px-2 py-0.5 text-[11px] text-accent">{chLabel(k)}</span>
))}
</div>
{res.matched.map((m) => (
<div key={m.rule.id} className="mt-2 flex items-start gap-2 text-[11px]">
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ok" />
<span className="text-fg"><b>{m.rule.name}</b> <span className="text-fg-muted">— {m.reasons.join(' · ')} → {m.rule.channels.map(chLabel).join(', ')}</span></span>
</div>
))}
{!!res.missed.length && (
<button onClick={() => setShowMiss((v) => !v)} className="mt-2 inline-flex items-center gap-1 text-[11px] text-fg-muted hover:text-fg">
<ChevronDown className={cn('h-3 w-3 transition-transform', showMiss && 'rotate-180')} /> почему не сработали ({res.missed.length})
</button>
)}
{showMiss && res.missed.map((m) => (
<div key={m.rule.id} className="mt-1 flex items-start gap-2 pl-1 text-[11px] text-fg-muted">
<XCircle className="mt-0.5 h-3 w-3 shrink-0" /> <span><b>{m.rule.name}</b> — {m.why}</span>
</div>
))}
</div>
{/* Журнал срабатываний */}
{!!log.length && (
<div className="mt-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">Журнал срабатываний</div>
<div className="space-y-1">
{log.map((f, i) => (
<div key={i} className="flex items-center gap-2 text-[11px]">
<Send className="h-3 w-3 shrink-0 text-accent" />
<span className="tnum text-fg-muted">{tm(f.ts)}</span>
<span className="min-w-0 flex-1 truncate text-fg">{SECTION_LABEL[f.ev.section as SectionId]} · {f.ev.severity} · {f.ev.objectName}</span>
<span className="shrink-0 text-fg-muted">→ {f.channels.map(chLabel).join(', ')}</span>
</div>
))}
</div>
</div>
)}
</div>
</Card>
);
}
+113
View File
@@ -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 (
<a
href={repoUrl(r)}
target="_blank"
rel="noreferrer"
className="group flex items-center gap-3 py-2.5 transition-colors hover:bg-muted/40"
>
<FolderGit2 className={cn('h-4 w-4 shrink-0', r.empty ? 'text-fg-muted' : 'text-accent')} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-fg">{r.name}</span>
{r.isNew && (
<Badge severity="ok">
<Sparkles className="h-3 w-3" /> новый
</Badge>
)}
{r.empty && (
<Badge severity="warning">
<CircleSlash className="h-3 w-3" /> пусто
</Badge>
)}
</div>
<div className="truncate text-[11px] text-fg-muted">{r.note}</div>
</div>
<span className="hidden items-center gap-1 text-[11px] text-fg-muted sm:inline-flex">
<GitBranch className="h-3.5 w-3.5" />
<span className={cn('font-mono', r.branch === 'master' && 'text-warn')}>{r.branch}</span>
</span>
{r.private ? (
<Lock className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-label="приватный" />
) : (
<Globe className="h-3.5 w-3.5 shrink-0 text-ok" aria-label="публичный" />
)}
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-transparent group-hover:text-fg-muted" />
</a>
);
}
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 (
<div>
<PageHeader
icon={FolderGit2}
title="Карта репозиториев"
desc="Gitea git1.servaki.online · owner german · канон = git"
actions={
<a
href={GITEA_BASE}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-fg-muted transition-colors hover:text-fg"
>
<ExternalLink className="h-3.5 w-3.5" /> Открыть Gitea
</a>
}
/>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard label="Всего репо" value={stats.total} digits={0} icon={FolderGit2} tone="accent" />
<KpiCard label="Приватных" value={stats.priv} digits={0} icon={Lock} tone="muted" />
<KpiCard label="Публичных" value={stats.pub} digits={0} icon={Globe} tone="ok" sub="только Heritage" />
<KpiCard label="Ветка master" value={stats.master} digits={0} icon={GitBranch} tone="warn" sub="cascade · vpn-clients" />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{byCat.map(({ cat, items }) => (
<Card key={cat}>
<CardHeader title={CATEGORY_LABEL[cat]} right={<Badge severity="info">{items.length}</Badge>} />
<div className="divide-y divide-border px-4 pb-2">
{items.map((r) => (
<RepoRow key={r.name} r={r} />
))}
</div>
</Card>
))}
</div>
<p className="mt-4 text-center text-xs text-fg-muted">
Источник истины — git1. Карта сверена вручную 2026-06-30 ({stats.total} репо, {stats.empty} пустой). Пуш — через
<span className="font-mono"> http.extraHeader</span>, токен в URL не сохраняется.
</p>
</div>
);
}
+123
View File
@@ -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 (
<div className="mt-2 rounded-xl border border-border bg-bg/40 p-3">
{/* Таймер + статус «на контроле» */}
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-semibold',
closed ? 'bg-ok/15 text-ok' : taken ? 'bg-info/15 text-info' : 'bg-crit/15 text-crit',
)}
>
<Timer className="h-3.5 w-3.5" />
{closed ? 'отработана' : taken ? 'В РАБОТЕ · на контроле' : 'НЕ ВЗЯТА'} · {fmtDur(elapsed)}
</span>
{stages.enroute && !stages.onsite && <span className="text-[11px] text-fg-muted">в пути: {fmtDur(now - new Date(stages.enroute).getTime())}</span>}
</div>
{/* Стадии-этапы с таймстампами */}
<div className="mt-2.5 flex flex-wrap gap-1.5">
{STAGES.map((st) => {
const ts = stages[st.key];
const done = !!ts;
return (
<button
key={st.key}
type="button"
disabled={!canEdit || done}
onClick={() => doStamp(st.key)}
title={done ? `${st.label}: ${tm(ts)} (+${fmtDur(new Date(ts).getTime() - startMs)})` : st.label}
className={cn(
'inline-flex items-center gap-1 rounded-lg border px-2 py-1 text-[11px] font-medium transition-colors',
done ? 'border-ok/40 bg-ok/10 text-ok' : canEdit ? 'border-border text-fg hover:bg-muted' : 'border-border text-fg-muted',
)}
>
{done ? <CheckCircle2 className="h-3 w-3" /> : <Clock className="h-3 w-3" />}
{st.short}
{done && <span className="tnum opacity-70">· {tm(ts)}</span>}
</button>
);
})}
</div>
{/* Журнал комментариев + типовые */}
{canEdit && (
<div className="mt-2.5">
<div className="flex flex-wrap gap-1">
{PRESET_COMMENTS.map((c) => (
<button key={c} type="button" onClick={() => doComment(c)} className="rounded-md border border-border px-1.5 py-0.5 text-[10px] text-fg-muted hover:bg-muted hover:text-fg">
{c}
</button>
))}
</div>
<div className="mt-1.5 flex gap-1.5">
<input
value={txt}
onChange={(e) => 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"
/>
<button onClick={() => doComment(txt)} className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-accent/15 text-accent hover:bg-accent/25"><Send className="h-3.5 w-3.5" /></button>
</div>
</div>
)}
{/* Лента отработки */}
{!!comments.length && (
<div className="mt-2 space-y-1 border-t border-border pt-2">
{comments.map((c, i) => (
<div key={i} className="flex items-start gap-2 text-[11px]">
<MessageSquarePlus className="mt-0.5 h-3 w-3 shrink-0 text-fg-muted" />
<span className="tnum text-fg-muted">{tm(c.ts)}</span>
<span className="min-w-0 flex-1 text-fg">{c.text}</span>
<span className="shrink-0 text-fg-muted">{c.author}</span>
</div>
))}
</div>
)}
</div>
);
}
+124
View File
@@ -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<AlarmStatus, string> = { new: 'новая', taken: 'в работе', gbr: 'ГБР', closed: 'отработана' };
const STATUS_SEV: Record<AlarmStatus, 'critical' | 'warning' | 'info' | 'ok'> = { 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<string, string>;
export function ArchiveModule() {
const handling = useAlarmHandling((s) => s.map);
const [open, setOpen] = useState<string | null>(null);
const [sev, setSev] = useState<'all' | 'critical' | 'warning' | 'info'>('all');
const [hour, setHour] = useState<number | null>(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 (
<div>
<PageHeader icon={Archive} title="Архив сработок" desc="История тревог ПЦН · отработка, фотофиксация, тайминги стадий" />
<SecurityTabs />
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<KpiCard label="Всего сработок" value={total} digits={0} icon={Activity} tone="info" />
<KpiCard label="Критических" value={crit} digits={0} icon={Siren} tone={crit ? 'crit' : 'muted'} />
<KpiCard label="Отработано" value={closed} digits={0} icon={CheckCircle2} tone="ok" />
<KpiCard label="Открыто" value={total - closed} digits={0} icon={XCircle} tone={total - closed ? 'warn' : 'muted'} />
</div>
{/* Интерактивная полоса по часам суток — клик по часу фильтрует список */}
<Card className="mt-4 p-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-fg-muted">Распределение по часам суток</span>
{hour != null && (
<button onClick={() => setHour(null)} className="rounded-md bg-accent/15 px-2 py-0.5 text-[11px] text-accent hover:bg-accent/25">фильтр: {String(hour).padStart(2, '0')}:00 ✕</button>
)}
</div>
<HourStrip counts={heatH} active={hour} onHour={setHour} labels />
<p className="mt-1.5 text-[10px] text-fg-muted">Кликните столбец — список отфильтруется по часу.</p>
</Card>
<Card className="mt-4">
<CardHeader title="Сработки" desc={`${list.length} записей · клик — детали отработки`} right={
<div className="flex gap-1">
{(['all', 'critical', 'warning', 'info'] as const).map((s) => (
<button key={s} onClick={() => setSev(s)} className={cn('rounded-md px-2 py-1 text-[11px]', sev === s ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:bg-muted')}>{s === 'all' ? 'все' : s}</button>
))}
</div>
} />
<div className="divide-y divide-border px-2 pb-2">
{list.map((a) => {
const o = guardObjects.find((g) => g.id === a.objId);
const h = handling[a.id];
const isOpen = open === a.id;
return (
<div key={a.id}>
<button onClick={() => setOpen(isOpen ? null : a.id)} className="flex w-full items-center gap-2 px-2 py-2.5 text-left hover:bg-muted/40">
<span className={cn('h-2.5 w-2.5 shrink-0 rounded-full', DOT[a.severity])} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">{o?.name ?? a.objId}</span>
<span className="hidden font-mono text-[10px] text-fg-muted sm:inline">{a.sia}·{a.cid}</span>
<span className="tnum shrink-0 text-[11px] text-fg-muted">{dt(a.ts)}</span>
<Badge severity={STATUS_SEV[a.status]}>{STATUS_LABEL[a.status]}</Badge>
<ChevronDown className={cn('h-4 w-4 shrink-0 text-fg-muted transition-transform', isOpen && 'rotate-180')} />
</button>
{isOpen && (
<div className="grid gap-3 px-2 pb-3 md:grid-cols-2">
{/* Реплей отработки: стадии + комментарии */}
<div className="rounded-lg border border-border bg-bg/40 p-3">
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">Отработка · стадии</div>
<div className="space-y-1">
{STAGES.map((st) => {
const ts = h?.stages?.[st.key];
return (
<div key={st.key} className="flex items-center gap-2 text-[11px]">
{ts ? <CheckCircle2 className="h-3 w-3 text-ok" /> : <Clock className="h-3 w-3 text-fg-muted" />}
<span className={cn('flex-1', ts ? 'text-fg' : 'text-fg-muted')}>{st.label}</span>
{ts && <span className="tnum text-fg-muted">{tm(ts)}</span>}
</div>
);
})}
</div>
{!!h?.comments?.length && (
<div className="mt-2 space-y-0.5 border-t border-border pt-2">
{h.comments.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[10px]"><span className="tnum text-fg-muted">{tm(c.ts)}</span><span className="flex-1 text-fg">{c.text}</span><span className="text-fg-muted">{c.author}</span></div>
))}
</div>
)}
{!h && <div className="mt-2 text-[10px] text-fg-muted">Отработка не зафиксирована (демо).</div>}
</div>
{/* Фотофиксация */}
<div className="rounded-lg border border-border bg-bg/40 p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-fg-muted"><Camera className="h-3.5 w-3.5" /> Фотофиксация</div>
{o?.photoUrl ? (
<img src={o.photoUrl} alt={`Снимок ${o.name}`} className="aspect-video w-full rounded-md border border-border object-cover" />
) : (
<div className="grid aspect-video w-full place-items-center rounded-md border border-dashed border-border text-[10px] text-fg-muted">нет снимка</div>
)}
<div className="mt-1.5 text-[10px] text-fg-muted">{SIA_LABEL[a.sia] ?? a.sia}{a.zone ? ` · зона ${a.zone}` : ''} · {o?.address}</div>
</div>
</div>
)}
</div>
);
})}
</div>
</Card>
</div>
);
}
@@ -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<ObjState, string> = { 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<GuardObject>(obj);
const set = (patch: Partial<GuardObject>) => 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 (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 max-h-[88vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-border bg-surface shadow-card">
<div className="sticky top-0 flex items-center justify-between border-b border-border bg-surface px-4 py-3">
<div className="flex items-center gap-2"><Building2 className="h-4 w-4 text-accent" /><span className="text-sm font-semibold">Охраняемый объект</span></div>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-4 p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div><label className={lab}>Название</label><input value={o.name} onChange={(e) => set({ name: e.target.value })} className={inp} /></div>
<div><label className={lab}>№ объекта ПЦН</label><input value={o.account} onChange={(e) => set({ account: e.target.value })} className={inp} /></div>
</div>
<div><label className={lab}>Адрес</label><input value={o.address} onChange={(e) => set({ address: e.target.value })} className={inp} /></div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div><label className={lab}>Широта</label><input value={o.lat} onChange={(e) => set({ lat: parseFloat(e.target.value.replace(',', '.')) || 0 })} inputMode="decimal" className={`${inp} tnum`} /></div>
<div><label className={lab}>Долгота</label><input value={o.lon} onChange={(e) => set({ lon: parseFloat(e.target.value.replace(',', '.')) || 0 })} inputMode="decimal" className={`${inp} tnum`} /></div>
<div><label className={lab}>Состояние</label><select value={o.state} onChange={(e) => set({ state: e.target.value as ObjState })} className={inp}>{STATES.map((s) => <option key={s} value={s}>{STATE_LABEL[s]}</option>)}</select></div>
<div><label className={lab}>Протокол</label><select value={o.proto} onChange={(e) => set({ proto: e.target.value as GuardObject['proto'] })} className={inp}><option value="SIA-DC09">SIA-DC09</option><option value="Surgard">Surgard</option></select></div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div><label className={lab}>Канал связи</label><input value={o.channel} onChange={(e) => set({ channel: e.target.value })} className={inp} /></div>
<div><label className={lab}>Камера (URL)</label><input value={o.cameraUrl || ''} onChange={(e) => set({ cameraUrl: e.target.value })} placeholder="https://cam…/snapshot" className={inp} /></div>
</div>
{/* Список дозвона */}
<div>
<div className="mb-1.5 flex items-center justify-between"><span className={lab.replace('mb-1 block ', '')}>Список дозвона</span>
<button onClick={() => set({ contacts: [...o.contacts, { name: '', role: 'Отв. лицо', phone: '' }] })} className="inline-flex items-center gap-1 rounded-md bg-accent/15 px-2 py-1 text-[11px] text-accent hover:bg-accent/25"><Plus className="h-3 w-3" /> контакт</button>
</div>
<div className="space-y-1.5">
{o.contacts.map((c, i) => (
<div key={i} className="flex gap-1.5">
<input value={c.name} onChange={(e) => set({ contacts: o.contacts.map((x, j) => j === i ? { ...x, name: e.target.value } : x) })} placeholder="ФИО" className={`${inp} flex-1`} />
<input value={c.role} onChange={(e) => set({ contacts: o.contacts.map((x, j) => j === i ? { ...x, role: e.target.value } : x) })} placeholder="роль" className={`${inp} w-32`} />
<input value={c.phone} onChange={(e) => set({ contacts: o.contacts.map((x, j) => j === i ? { ...x, phone: e.target.value } : x) })} placeholder="+7…" className={`${inp} w-36`} />
<button onClick={() => set({ contacts: o.contacts.filter((_, j) => j !== i) })} className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-crit"><Trash2 className="h-4 w-4" /></button>
</div>
))}
{!o.contacts.length && <p className="text-[11px] text-fg-muted">Контактов нет — добавьте.</p>}
</div>
</div>
{/* Зоны */}
<div>
<div className="mb-1.5 flex items-center justify-between"><span className={lab.replace('mb-1 block ', '')}>Зоны объекта</span>
<button onClick={() => set({ zones: [...o.zones, { n: o.zones.length + 1, name: '', kind: 'burglar' }] })} className="inline-flex items-center gap-1 rounded-md bg-accent/15 px-2 py-1 text-[11px] text-accent hover:bg-accent/25"><Plus className="h-3 w-3" /> зона</button>
</div>
<div className="space-y-1.5">
{o.zones.map((z, i) => (
<div key={i} className="flex gap-1.5">
<input value={z.n} onChange={(e) => set({ zones: o.zones.map((x, j) => j === i ? { ...x, n: Number(e.target.value) || 0 } : x) })} type="number" className={`${inp} tnum w-16`} />
<input value={z.name} onChange={(e) => set({ zones: o.zones.map((x, j) => j === i ? { ...x, name: e.target.value } : x) })} placeholder="название зоны" className={`${inp} flex-1`} />
<select value={z.kind} onChange={(e) => set({ zones: o.zones.map((x, j) => j === i ? { ...x, kind: e.target.value as (typeof ZONE_KINDS)[number] } : x) })} className={`${inp} w-32`}>{ZONE_KINDS.map((k) => <option key={k} value={k}>{k}</option>)}</select>
<button onClick={() => set({ zones: o.zones.filter((_, j) => j !== i) })} className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-crit"><Trash2 className="h-4 w-4" /></button>
</div>
))}
{!o.zones.length && <p className="text-[11px] text-fg-muted">Зон нет — добавьте.</p>}
</div>
</div>
</div>
<div className="sticky bottom-0 flex justify-end gap-2 border-t border-border bg-surface px-4 py-3">
<button onClick={onClose} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-fg hover:bg-muted">Отмена</button>
<button onClick={() => { upsert(o); onClose(); }} disabled={!o.name.trim()} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-xs font-semibold text-[#06210f] hover:opacity-90 disabled:opacity-40"><Check className="h-3.5 w-3.5" /> Сохранить</button>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More