Compare commits

..
28 Commits
Author SHA1 Message Date
DenozordecandCursor ec43591a99 feat(db): перевести хранилище с SQLite на PostgreSQL
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 01:36:48 +07:00
DenozordecandCursor 0e5fb065e2 fix(backend): уменьшить лишние записи SQLite
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 2m8s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 12s
Убрать WAL TRUNCATE с hot path NetFlow, не писать неизменённые UPDATE
и служебные INSERT, кэшировать карты topology/analytics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 20:22:20 +07:00
DenozordecandCursor 5188b2aff2 fix(network-map): обрезать пунктир до сервиса и показать пути клиентов
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m58s
Docker images / frontend-image (push) Successful in 3m17s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 13s
Линия от обода EN до рамки сервиса; таблица клиент, узел и сервис с подсветкой на карте.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:39:18 +07:00
DenozordecandCursor cd1fd2c9d3 fix(network-map): рисовать пунктир и скорость до сервиса
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m41s
Docker images / frontend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
Якорь hop на EN даже без nextHop; линия ARN\to Google с NetflowRateBadge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:02:28 +07:00
DenozordecandCursor 77425cca32 fix(network-map): якорить сервисы на выходную ноду
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m46s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 16s
Пунктир от EN, а не от JH; перетаскивание узлов сервисов; иконка Google без foreignObject.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 16:10:39 +07:00
DenozordecandCursor 29d245cde3 feat(traffic): enhance flow analytics and brand classification
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m12s
Docker images / frontend-image (push) Successful in 4m3s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 12s
- Introduced `pickInternetPeer` function to improve IP classification by selecting the appropriate public IP from source and destination.
- Updated `buildFlowAnalytics` and related functions to utilize the new peer selection logic, enhancing accuracy in flow analytics.
- Added tests for new classifications and ensured existing tests cover new scenarios for Google and Cloudflare.
- Refactored traffic flow brand mappings to include additional CIDR ranges for Google and Cloudflare.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 15:35:52 +07:00
DenozordecandCursor 7a491a325d fix(network-map): убрать тяжёлую классификацию из poll карты
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m53s
Docker images / frontend-image (push) Successful in 3m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 12s
Не сканировать RIPE и каталог по каждой строке окна; сервисы по уникальным dst и кэшу ASN. Порог доли на карте настраиваемый и отключаемый.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:17:04 +07:00
DenozordecandCursor db64621122 feat(network-map): показать конечные сервисы на карте сети
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 2m0s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 12s
NetFlow ≥ 5% окна, узлы с логотипом бренда справа от EN, скорость потока на рёбрах к сервисам.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 13:30:45 +07:00
DenozordecandCursor 6332d83a12 feat(traffic): показать поток NetFlow на карте сети
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 2m14s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 11s
Скорость между узлами считается как в Трафике (5 мин, без overlay/mesh), отдельно от ёмкости WAN и BT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 12:37:47 +07:00
DenozordecandCursor 3834c40aa8 fix(traffic): починить сборку tsc для частичных IPFIX-фикстур
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m50s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 11s
Тесты не должны попадать в Docker tsc; ingest принимает ParsedFlowInput и нормализует поля.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:45:22 +07:00
DenozordecandCursor 90c8c393e5 feat(traffic): разделить плоскости IPFIX и показать путь JH→EN
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Failing after 1m31s
Docker images / frontend-image (push) Successful in 2m36s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
Считать payload отдельно от overlay GRE/ESP и mesh; вкладка Пути и KPI Wire из счётчиков iface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:39:14 +07:00
DenozordecandCursor cb799da13a fix(netflow): изолировать коллектор IPFIX и срезать раздувание базы
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m23s
Docker images / frontend-image (push) Successful in 3m16s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 57s
Docker images / publish-release (push) Successful in 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 09:50:24 +07:00
DenozordecandCursor e0ddb17539 feat(traffic): классифицировать потоки по брендам и разгрузить ingest
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 1m48s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Починить ISO-страну Cloudflare, не держать holder как сервис, клик по срезу открывает Сессии. Не вызывать REST /interface с UDP, flush SQLite пачкой — uptime не должен ловить timeout 10s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 03:01:14 +07:00
DenozordecandCursor 2820683cba feat(traffic): добавить карту и срезы IPFIX по странам и сессиям
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m6s
Docker images / frontend-image (push) Successful in 3m23s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 14s
Имена ifIndex пишутся по обоим ключам REST, дедуп 5-tuple по max байт, RIPEstat только из prefix-кэша, карта откуда-куда в Frame.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 02:13:21 +07:00
DenozordecandCursor 37167f78e3 feat(traffic): показать аналитику IPFIX по серверам и интерфейсам
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 11s
Резолвить ifIndex в имена RouterOS, дать вкладке Потоки ту же оболочку сервер/клиент/iface, что у обычного трафика, и обновлять срезы live без перезагрузки.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 01:21:12 +07:00
DenozordecandCursor cf68b59b3f fix(traffic): сбрасывать src-address Traffic Flow в авто
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m2s
Docker images / frontend-image (push) Successful in 3m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 12s
Target с адресом wg-flow помечался invalid, IPFIX не уходил на коллектор.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 00:37:12 +07:00
DenozordecandCursor 5e512407e5 fix(traffic): записывать потоки при подмене UDP-источника в Docker
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 2m57s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 11s
Пакеты IPFIX доходили, но разговоры отбрасывались, если Docker подменял адрес jump-host на 172.x.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 00:02:50 +07:00
DenozordecandCursor 13889005f8 refactor(traffic): update traffic flow host file generation and configuration scripts
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 3m2s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
- Renamed and refactored functions for better clarity, including `buildHostComposeSnippet` to `buildHostComposeOverride` and `buildHostNftSnippet` to `buildHostLinuxInstallSh`.
- Introduced a new script for Linux installation of WireGuard, enhancing the setup process for Docker hosts.
- Updated the `generateNativeConf` function to conditionally include the listen port in the configuration.
- Adjusted the `FlowOverlaySheet` component to default to the new Linux tab and improved the user interface for selecting jump-hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 23:20:12 +07:00
DenozordecandCursor f0dc5acfd3 fix(traffic): сделать JH сервером WG и патчить traffic-flow по id
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m52s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 9s
RouterOS не принимает PATCH без .id у singleton. Пир на jump-host без endpoint: клиент — хост MM с Endpoint JH:13232.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 22:58:31 +07:00
DenozordecandCursor 63bed28251 fix(ci): импортировать TrafficFlowHostFile в клиенте overlay
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 56s
Docker images / frontend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 16s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 22:24:44 +07:00
DenozordecandCursor 95dcd3df58 fix(traffic): спрашивать endpoint MM в sheet подключения JH
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Failing after 2m38s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 22:16:41 +07:00
DenozordecandCursor 1e9312acbd feat(traffic): добавить приём Traffic Flow с jump-host
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 3m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 12s
Чтобы видеть «кто с кем», а не только объём порта: IPFIX внутри WG на хосте Docker MM, REST-счётчики не трогаем.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 21:54:30 +07:00
Denozordec 5884bd8873 feat(traffic, users): enhance interface and peer management features
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Added support for managing peer information in interface bindings, including new fields for peerPublicKey and peerName in the BoundIfaceTraffic interface. Updated the database schema to include these fields in user_interface_bindings and traffic_samples tables. Enhanced the UI components to display peer details alongside interface names, improving user experience and clarity in the traffic management system. Updated relevant functions and services to handle peer-specific logic, ensuring robust integration across the application.
2026-09-06 20:45:37 +07:00
Denozordec fc161506e7 feat(users): enhance interface type mapping and parsing logic
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m30s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 8s
Updated the mapRosInterfaceType function to support additional interface types, including "gre-tunnel" and improved handling for various input formats. Enhanced the parseRawInterfaces function to accommodate new interface types in the test cases, ensuring accurate parsing and type assignment. Added corresponding test cases to validate the new functionality and ensure robustness in interface type handling.
2026-09-06 19:35:01 +07:00
Denozordec b3e50a1f5f feat(users): implement user management features and database schema
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m57s
Docker images / frontend-image (push) Successful in 4m8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 44s
Docker images / publish-release (push) Successful in 11s
Added user management functionality, including the creation of app_users and user_interface_bindings tables in the database. Implemented API routes for user data retrieval and permissions handling. Enhanced the traffic monitoring system to include user traffic statistics and interface bindings. Updated relevant components and services to support the new user features, improving overall application functionality and user experience.
2026-09-06 19:20:05 +07:00
Denozordec fe32c9313a feat(ui): integrate KpiStatGrid for enhanced statistics display
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
2026-09-06 17:58:05 +07:00
Denozordec 6123660346 feat(terminal, wireguard): enhance UI components and functionality
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 10s
Updated the TerminalPage to include a new ServerTileRail for server selection and added QuickCmds for quick command execution. Enhanced the WireGuardPage with new tabs for interface and peer management, improved server selection handling, and added support for displaying peer details. Refactored data grids to support compact server views and improved empty state handling. Introduced new hooks for managing server states and improved user experience across components.
2026-09-06 01:23:40 +07:00
Denozordec b680f882cc feat(ui): enhance CodeExportSheet and WgExportSheet components
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m47s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
Updated CodeExportSheet to improve UI elements, including the addition of a Badge component for live status indication and refined layout adjustments. Modified WgExportSheet to simplify format labels and incorporate live content fetching capabilities. Adjusted styles for better visual consistency and user experience.
2026-09-06 00:23:51 +07:00
222 changed files with 24407 additions and 5069 deletions
+42 -1
View File
@@ -38,8 +38,49 @@ jobs:
node .ci/scripts/compute-release.mjs
test -f .ci/release/release-manifest.json
backend-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Start PostgreSQL 18
shell: bash
run: |
set -euo pipefail
docker run -d --name mm-pg \
-e POSTGRES_USER=mmapp \
-e POSTGRES_PASSWORD=mmapp \
-e POSTGRES_DB=mmapp \
-p 5432:5432 \
postgres:18-alpine
for i in $(seq 1 40); do
if docker exec mm-pg pg_isready -U mmapp -d mmapp; then
exit 0
fi
sleep 1
done
echo "PostgreSQL не поднялся"
exit 1
- name: Install and test backend
shell: bash
env:
DATABASE_URL: postgres://mmapp:mmapp@127.0.0.1:5432/mmapp
run: |
set -euo pipefail
npm ci
npm run test --prefix backend
backend-image:
needs: prepare-release
needs:
- prepare-release
- backend-test
runs-on: ubuntu-latest
steps:
- name: Checkout
+30 -21
View File
@@ -19,7 +19,7 @@
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|-----------|------|------|----------------|--------------|
| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
| Backend | `backend/` | Fastify 5, TypeScript ESM, SQLite, Drizzle | 8000 | `…-backend` |
| Backend | `backend/` | Fastify 5, TypeScript ESM, PostgreSQL 18, Drizzle | 8000 | `…-backend` |
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
@@ -43,11 +43,11 @@ flowchart TB
Browser["Браузер"]
NextDev["Next.js :3000"]
FastifyDev["Fastify :8000"]
SqliteDev["SQLite файл"]
PostgresDev["PostgreSQL 18"]
Contracts["@mmapp/contracts"]
Browser --> NextDev
Browser -->|"fetch /api, CORS"| FastifyDev
FastifyDev --> SqliteDev
FastifyDev --> PostgresDev
Contracts --> NextDev
Contracts --> FastifyDev
end
@@ -60,7 +60,8 @@ flowchart TB
subgraph prod [Прод-сервер]
FE["mmapp-frontend :3000"]
BE["mmapp-backend :8000"]
DBVol["volume backend-data /app/data"]
PG["mmapp-postgres"]
DBVol["volume sqlite ETL /app/data"]
UPD["mmapp-updater"]
Sock["/var/run/docker.sock"]
Reg --> FE
@@ -69,6 +70,7 @@ flowchart TB
UPD --> Sock
UPD --> FE
UPD --> BE
BE --> PG
BE --> DBVol
BrowserProd["Клиент"] --> FE
BrowserProd -->|"live + backendUrl"| BE
@@ -117,7 +119,7 @@ sequenceDiagram
```
- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
- Параллельные jobs: `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
- Параллельные jobs: `backend-test` (PostgreSQL 18), `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
- Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**.
- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
@@ -125,7 +127,8 @@ sequenceDiagram
| Зависимость | Реализация |
|-------------|------------|
| SQLite | Не отдельный контейнер. Файл `mikrotik.db` в томе `backend-data``/app/data` (`DATABASE_PATH=/app/data/mikrotik.db` в образе backend). |
| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При первом старте, если PG пустой, backend сам импортирует данные и ставит маркер. Повторный старт не копирует заново. |
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
## Локальная разработка
@@ -134,7 +137,8 @@ sequenceDiagram
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся (нужен только для одноразового boot-ETL из `mikrotik.db`).
- Локально нужен PostgreSQL 18 (`deploy/docker-compose.postgres.yml` или `DATABASE_URL`).
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
### Запуск
@@ -161,7 +165,8 @@ Backend — скопировать `backend/.env.example` в `backend/.env`:
| Переменная | По умолчанию | Назначение |
|------------|--------------|------------|
| `DATABASE_PATH` | `./mikrotik.db` | путь к файлу SQLite |
| `DATABASE_URL` | `postgres://mmapp:mmapp@127.0.0.1:5432/mmapp` | подключение к PostgreSQL |
| `DATABASE_PATH` | `./mikrotik.db` | SQLite только для boot-ETL, если PG пустой |
| `PORT` | `8000` | порт Fastify |
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
@@ -179,12 +184,10 @@ Frontend — в репозитории нет корневого `.env.example`.
npm run build -w @mmapp/contracts
```
После изменения схем Drizzle:
Схема PostgreSQL накатывается при старте backend из [`backend/drizzle/0000_postgresql.sql`](backend/drizzle/0000_postgresql.sql). Опциональный ETL:
```bash
npm --prefix backend run db:generate
npm --prefix backend run db:migrate
npm --prefix backend run db:studio
npm --prefix backend run db:migrate-from-sqlite
```
## CI/CD (Gitea Actions)
@@ -281,7 +284,8 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|--------|------------------|----------------|----------------------|------|-----------|
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data` `/app/data` | `unless-stopped` |
| postgres | `mmapp-postgres` | `postgres:18-alpine` | нет (внутренняя сеть) | `mmapp-pgdata` | `unless-stopped` |
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | sqlite ETL → `/app/data` | `unless-stopped` |
| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state``/state`, `targets.json``/etc/updater/targets.json:ro` | `unless-stopped` |
@@ -297,11 +301,14 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
### Переменные окружения (прод)
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`, `DATABASE_PATH=/app/data/mikrotik.db`; в compose обычно переопределяют только CORS):
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`; в compose задают `DATABASE_URL`, `DATABASE_PATH` для ETL и CORS):
| Переменная | Источник в compose | Назначение |
|------------|-------------------|------------|
| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
| `DATABASE_URL` | `postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp` | PostgreSQL |
| `DATABASE_PATH` | `/app/data/mikrotik.db` | SQLite на томе для одноразового ETL |
| `POSTGRES_PASSWORD` | обязателен | пароль пользователя `mmapp` |
**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
@@ -312,7 +319,7 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
| `HEALTH_TIMEOUT_SECONDS` | `120` | ожидание HTTP health |
| `HEALTH_TIMEOUT_SECONDS` | `600` | ожидание HTTP health (первый boot с ETL может занять минуты) |
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
| `REGISTRY` | `git.shx.one` | registry для `docker login` |
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
@@ -516,18 +523,20 @@ docker compose up -d --force-recreate
### Откат
- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы SQLite** updater не выполняет — нужен отдельный backup тома `backend-data` / файла `mikrotik.db`.
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы PostgreSQL** updater не выполняет — нужен `pg_dump` / restore или том `mmapp-pgdata`.
### Резервное копирование SQLite
### Резервное копирование PostgreSQL
Том `backend-data` (или `mmapp-backend-data` при явном `docker volume create`). Пример остановки backend для консистентной копии:
Том `mmapp-pgdata` и (до cleanup) sqlite `mikrotik.db` на `/app/data`. Из UI: Настройки → «Скачать бэкап» (`pg_dump -Fc`). С хоста:
```bash
docker stop mmapp-backend
docker run --rm -v backend-data:/data -v $(pwd):/backup alpine tar czf /backup/mikrotik-db-backup.tar.gz -C /data .
docker start mmapp-backend
docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
```
Восстановление: UI «Восстановить из файла» или `pg_restore --clean --if-exists -d …`.
Том sqlite после успешного ETL и smoke **не удаляйте сразу** — оставьте как fallback, затем уберите `mikrotik.db`, когда маркер `data_migration.sqlite_imported_at` подтверждён.
### Отладка
| Действие | Команда |
+1 -1
View File
@@ -2846,7 +2846,7 @@ export default function AlertsPage() {
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3 text-[11px] text-muted-foreground leading-relaxed space-y-2 border-t border-border/60 pt-2">
<p>
Движок читает последние сэмплы из SQLite (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
Движок читает последние сэмплы из PostgreSQL (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
срабатывает только если цепочка состояний совпадает с текстом правила.
</p>
<ul className="list-disc pl-4 space-y-1.5">
+34 -23
View File
@@ -8,8 +8,7 @@ import { FileImportDialog } from "@/components/file-import-dialog"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import type { Backup, Server } from "@/lib/data"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
@@ -343,27 +342,39 @@ export default function BackupsPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-center gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка бэкапов"
items={[
{
id: "all",
label: "Всего бэкапов",
value: backupList.length,
icon: <HardDriveIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "auto",
label: "Авто",
value: autoCount,
icon: <ClockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "manual",
label: "Вручную",
value: manualCount,
icon: <PlusIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "servers",
label: "Серверов охвачено",
value: serverCount,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Info bar */}
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
+69 -33
View File
@@ -10,6 +10,7 @@ import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
@@ -20,7 +21,7 @@ import {
RefreshCwIcon, DownloadIcon, SearchIcon,
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
XIcon, AlertCircleIcon,
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
@@ -463,22 +464,39 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
return (
<div className="flex flex-col gap-5">
{/* summary row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
].map(s => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка префиксов BGP"
items={[
{
id: "rx",
label: "Всего префиксов",
value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "active",
label: "Активных маршрутов",
value: fmtNum(totalActive),
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "ebgp",
label: "eBGP сессий",
value: ebgpSessions,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ibgp",
label: "iBGP сессий",
value: ibgpSessions,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */}
@@ -722,22 +740,40 @@ export default function BgpPage() {
</span>
)}
{/* KPI strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Сессий всего", value: sessions.length, color: "" },
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
].map(s => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка BGP"
items={[
{
id: "sessions",
label: "Сессий всего",
value: sessions.length,
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "established",
label: "Established",
value: established,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-estab",
label: "Не установлено",
value: notEstab,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
variant: notEstab > 0 ? "warning" : "default",
},
{
id: "prefixes",
label: "Получено префиксов",
value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* alert: not-established sessions */}
{notEstab > 0 && (
+100 -60
View File
@@ -7,12 +7,13 @@ import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { CertStatus, Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -119,42 +120,41 @@ function CertPartKpi({
expired: CertificateDto[]
}) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
<KpiStatGrid
aria-label="Сводка сертификатов"
items={[
{
id: "all",
label: "Всего",
value: displayCerts.length,
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "valid",
label: "Действующих",
value: displayCerts.filter((c) => c.status === "valid").length,
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
icon: <BadgeCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "expiring",
label: "Истекают",
value: expiring.length,
icon: <AlertTriangleIcon className="size-4 text-amber-500" />,
icon: <AlertTriangleIcon className="size-4" />,
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
variant: expiring.length > 0 ? "warning" : "default",
},
{
id: "expired",
label: "Истёкших",
value: expired.length,
icon: <AlertCircleIcon className="size-4 text-red-500" />,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
variant: expired.length > 0 ? "destructive" : "default",
},
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
]}
/>
)
}
@@ -437,6 +437,8 @@ export default function CertificatesPage() {
const [issueTrustWww, setIssueTrustWww] = useState(true)
const [issueTrustApi, setIssueTrustApi] = useState(true)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
"https://acme-v02.api.letsencrypt.org/directory",
)
@@ -451,6 +453,27 @@ export default function CertificatesPage() {
return routerCertificates.map(mockToDto)
}, [prefsHydrated, isLive, certificates])
const displayServers = isLive ? serverList : mockServers
const scopedCerts = useMemo(() => {
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId)
}, [displayCerts, selectedServerId])
const certRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayCerts])
const serverById = useMemo(() => {
const map = new Map<string, Server>()
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
@@ -515,13 +538,13 @@ export default function CertificatesPage() {
}, [isLive, loadLive, loadAcmeSettings])
const expiring = useMemo(
() => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
[displayCerts],
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
[scopedCerts],
)
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts])
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
const filtered = useMemo(() => {
return displayCerts.filter((c) => {
return scopedCerts.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true
const q = search.toLowerCase()
@@ -532,7 +555,7 @@ export default function CertificatesPage() {
c.sans.some((s) => s.includes(q))
)
})
}, [displayCerts, search, statusFilter])
}, [scopedCerts, search, statusFilter])
async function handleRefresh() {
if (!liveReady) return
@@ -625,35 +648,52 @@ export default function CertificatesPage() {
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<>
<Button
variant="outline"
size="sm"
disabled={!liveReady || loadState === "loading"}
onClick={() => {
void handleRefresh()
}}
>
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<>
<ServerRailLayout
items={certRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && loadState === "loading" && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
disabled={!liveReady || loadState === "loading"}
onClick={() => {
void handleRefresh()
}}
>
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button
size="sm"
disabled={!liveReady || issueBusy}
onClick={() => {
setIssueStep(1)
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true)
}}
>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{isLive && backendStatus === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
@@ -673,7 +713,7 @@ export default function CertificatesPage() {
</div>
)}
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} />
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
{liveReady && (
<CertPartAcmeSettings
@@ -720,7 +760,7 @@ export default function CertificatesPage() {
<CertPartReference />
</div>
</div>
</ServerRailLayout>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
@@ -753,7 +793,7 @@ export default function CertificatesPage() {
<StepperContent key={s} value={s}>
<CertPartIssueForm
step={s as 1 | 2 | 3 | 4}
serverList={serverList}
serverList={displayServers}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
@@ -812,6 +852,6 @@ export default function CertificatesPage() {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
</div>
</>
)
}
+34 -16
View File
@@ -11,6 +11,7 @@ import {
ACTION_COLOR,
} from "@/components/data-grids/communities-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -160,22 +161,39 @@ export default function CommunitiesPage() {
</p>
)}
{/* ── summary ── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Всего communities", value: String(listData.length) },
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
].map(({ label, value }) => (
<Frame key={label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка communities"
items={[
{
id: "all",
label: "Всего communities",
value: String(listData.length),
icon: <TagIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: String(listData.filter((c) => c.enabled).length),
icon: <CheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "standard",
label: "Стандартных",
value: String(listData.filter((c) => c.type === "standard").length),
icon: <TagIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "filters",
label: "Использует фильтры",
value: String(new Set(listData.flatMap((c) => c.filterIds)).size),
icon: <FilterIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<div className="grid grid-cols-[1fr_320px] gap-5">
{/* ── main table ── */}
+35 -22
View File
@@ -6,7 +6,7 @@ import { routerContainers, servers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@@ -286,27 +286,40 @@ export default function ContainersPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка контейнеров"
items={[
{
id: "all",
label: "Всего",
value: routerContainers.length,
icon: <BoxIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "running",
label: "Running",
value: running,
icon: <PlayIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "stopped",
label: "Stopped",
value: stopped,
icon: <StopCircleIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "errors",
label: "Ошибок",
value: errors,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: "text-destructive",
variant: errors > 0 ? "destructive" : "default",
},
]}
/>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
+49 -90
View File
@@ -1,15 +1,13 @@
"use client"
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { usePathname } from "next/navigation"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { StatusDot } from "@/components/status-dot"
import { StatusBadge } from "@/components/status-badge"
import { Sparkline } from "@/components/sparkline"
import { LatencyChart } from "@/components/dashboard/latency-chart"
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
import { InternetPathMapCard } from "@/components/dashboard/internet-path-map"
@@ -47,49 +45,6 @@ function makeApiFetch(backendUrl: string) {
}
}
function StatCard({
label, value, unit, delta, deltaDir, spark, sparkColor, icon,
}: {
label: string; value: string; unit?: string; delta?: string
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
icon?: ReactNode
}) {
return (
<Frame className="h-full">
<FramePanel className="relative isolate flex h-full flex-col overflow-hidden">
<div className="relative z-10 flex items-start gap-3">
{icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className="size-10.5 text-muted-foreground"
>
{icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{label}</p>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</span>
{unit ? <span className="text-muted-foreground text-sm">{unit}</span> : null}
</div>
{delta ? (
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
{delta}
</p>
) : null}
</div>
</div>
{spark && spark.length > 1 ? (
<div className="absolute right-4 bottom-4 opacity-60">
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
</div>
) : null}
</FramePanel>
</Frame>
)
}
function fmtIntRu(n: number): string {
return n.toLocaleString("ru-RU")
}
@@ -725,49 +680,53 @@ export default function DashboardPage() {
<div className="flex-1 overflow-y-auto">
<div className="p-6 flex flex-col gap-6">
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Серверы онлайн"
value={dashboardKpi.servers.value}
unit={dashboardKpi.servers.unit}
delta={dashboardKpi.servers.delta}
deltaDir={dashboardKpi.servers.deltaDir}
spark={dashboardKpi.servers.spark}
sparkColor={dashboardKpi.servers.sparkColor}
icon={<ServerIcon aria-hidden />}
/>
<StatCard
label="Активные фильтры"
value={dashboardKpi.filters.value}
unit={dashboardKpi.filters.unit}
delta={dashboardKpi.filters.delta}
deltaDir={dashboardKpi.filters.deltaDir}
spark={dashboardKpi.filters.spark}
sparkColor={dashboardKpi.filters.sparkColor}
icon={<FilterIcon aria-hidden />}
/>
<StatCard
label="BGP-префиксы"
value={dashboardKpi.bgp.value}
unit={dashboardKpi.bgp.unit}
delta={dashboardKpi.bgp.delta}
deltaDir={dashboardKpi.bgp.deltaDir}
spark={dashboardKpi.bgp.spark}
sparkColor={dashboardKpi.bgp.sparkColor}
icon={<GitMergeIcon aria-hidden />}
/>
<StatCard
label="Активные алерты"
value={dashboardKpi.alerts.value}
unit={dashboardKpi.alerts.unit}
delta={dashboardKpi.alerts.delta}
deltaDir={dashboardKpi.alerts.deltaDir}
spark={dashboardKpi.alerts.spark}
sparkColor={dashboardKpi.alerts.sparkColor}
icon={<BellIcon aria-hidden />}
/>
</div>
<KpiStatGrid
aria-label="Сводка дашборда"
items={[
{
id: "servers",
label: "Серверы онлайн",
value: dashboardKpi.servers.unit
? `${dashboardKpi.servers.value} ${dashboardKpi.servers.unit}`
: dashboardKpi.servers.value,
hint: dashboardKpi.servers.delta,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
variant: dashboardKpi.servers.deltaDir === "down" ? "warning" : "default",
},
{
id: "filters",
label: "Активные фильтры",
value: dashboardKpi.filters.unit
? `${dashboardKpi.filters.value} ${dashboardKpi.filters.unit}`
: dashboardKpi.filters.value,
hint: dashboardKpi.filters.delta,
icon: <FilterIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "bgp",
label: "BGP-префиксы",
value: dashboardKpi.bgp.unit
? `${dashboardKpi.bgp.value} ${dashboardKpi.bgp.unit}`
: dashboardKpi.bgp.value,
hint: dashboardKpi.bgp.delta,
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-primary",
},
{
id: "alerts",
label: "Активные алерты",
value: dashboardKpi.alerts.unit
? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}`
: dashboardKpi.alerts.value,
hint: dashboardKpi.alerts.delta,
icon: <BellIcon className="size-4" />,
iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground",
variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default",
},
]}
/>
{/* Latency chart + Events */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
+38 -26
View File
@@ -6,8 +6,7 @@ import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import {
@@ -55,6 +54,7 @@ import {
type SchedulerJobGridRow,
} from "@/components/data-grids/data-collection-scheduler-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
import { cn } from "@/lib/utils"
import {
AlertCircleIcon,
@@ -220,7 +220,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</Alert>
) : null}
<p className="text-xs text-muted-foreground">
Запись в SQLite на{" "}
Запись в PostgreSQL на{" "}
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
{" "} строки GRE и BGP для движка оповещений.
</p>
@@ -347,7 +347,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
<p className="text-xs text-muted-foreground">
Снимок на{" "}
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
. Данные для правил берутся из SQLite после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).
. Данные для правил берутся из PostgreSQL после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).
</p>
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div>
@@ -967,13 +967,13 @@ export default function DataCollectionPage() {
sub: uptimeCollector?.scheduler?.jobs?.length
? "По сохранённым задачам планировщика"
: "По переключателям на этой странице",
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
icon: <CalendarClockIcon className="size-4" />,
},
{
label: "Сейчас выполняется",
value: String(runningJobsCount),
sub: "Фоновые прогоны планировщика",
icon: <LoaderCircleIcon className="size-4 text-amber-500" />,
icon: <LoaderCircleIcon className="size-4" />,
},
{
label: "Трафик — последний сбор",
@@ -982,16 +982,16 @@ export default function DataCollectionPage() {
: "—",
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
icon: trafficCollector?.lastError ? (
<XCircleIcon className="size-4 text-destructive" />
<XCircleIcon className="size-4" />
) : (
<CheckCircleIcon className="size-4 text-emerald-500" />
<CheckCircleIcon className="size-4" />
),
},
{
label: "Журнал (в списке)",
value: String(schedulerRuns.length),
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
icon: <DatabaseIcon className="size-4 text-sky-500" />,
icon: <DatabaseIcon className="size-4" />,
},
],
[
@@ -1064,22 +1064,32 @@ export default function DataCollectionPage() {
{isLive && (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка сбора данных"
items={stats.map((s, i) => ({
id: `dc-${i}`,
label: s.label,
value: s.value,
hint: s.sub,
icon: s.icon,
iconClassName:
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
? "text-destructive"
: s.label === "Сейчас выполняется" && runningJobsCount > 0
? "text-warning"
: s.label === "Журнал (в списке)" && errorRunsInView
? "text-destructive"
: s.label === "Трафик — последний сбор"
? "text-success"
: "text-muted-foreground",
variant:
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
? "destructive" as const
: s.label === "Журнал (в списке)" && errorRunsInView
? "warning" as const
: "default" as const,
}))}
/>
<DataPageCard>
<div className="border-b border-border px-5 py-4">
@@ -1201,9 +1211,11 @@ export default function DataCollectionPage() {
</div>
</DataPageCard>
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
<OpsPanel
title="Журнал прогонов"
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
description="Таблица `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
headerRight={
<div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
+133 -137
View File
@@ -36,6 +36,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
// ── helpers ────────────────────────────────────────────────────────────────────
@@ -1401,6 +1403,25 @@ export default function FiltersPage() {
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const filterRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(rulesets.find((r) => r.serverId === s.id)?.rules.length ?? 0),
}))
), [allServers, rulesets])
const handleSelectServer = useCallback((id: string) => {
setSelectedServerId(id)
setSearch("")
}, [])
const currentRules = useMemo(
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
[rulesets, selectedServerId],
@@ -1566,142 +1587,117 @@ export default function FiltersPage() {
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
actions={
<>
{isLive && (
<>
<Button
variant="outline"
size="sm"
onClick={syncFromRouter}
disabled={syncBusy !== null}
title="Синхронизация Router → БД"
>
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
</Button>
<Button
variant="outline"
size="sm"
onClick={syncToRouter}
disabled={syncBusy !== null}
title="Синхронизация БД → Router"
>
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void fetchRouterCompare()}
disabled={syncBusy !== null || routerCompareLoading}
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
className="gap-1.5"
>
{routerCompareLoading ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Сверить
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
<FileCodeIcon className="size-4" />RouterOS
</Button>
<Button
variant="outline" size="sm"
onClick={() => setCopyOpen(true)}
disabled={currentRules.length === 0}
title="Копировать правила на другой сервер"
>
<CopyIcon className="size-4" />Копировать
</Button>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
}
/>
{isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</div>
)}
{/* ── summary bar + server chips (same pattern as monitoring) ── */}
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего правил</span>
<span className="font-semibold tabular-nums">{totalRules}</span>
</div>
{(() => {
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
if (bhTotal === 0) return null
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
{bhTotal} blackhole
</span>
)
})()}
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{allServers.map(s => {
const count = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0
const active = selectedServerId === s.id
return (
<button key={s.id}
onClick={() => { setSelectedServerId(s.id); setSearch("") }}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
!s.enabled && !active && "opacity-40",
)}>
<StatusDot status={s.status} />
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
<span className={cn(
"tabular-nums font-semibold",
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
)}>{count}</span>
</button>
)
})}
</div>
{/* ── toolbar ── */}
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRules.length !== currentRules.length
? `${filteredRules.length} из ${currentRules.length} правил`
: `${currentRules.length} правил`
<>
<ServerRailLayout
items={filterRailItems}
selectedId={selectedServerId}
onSelect={handleSelectServer}
showAll={false}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
actions={
<>
<ServerRailMobileButton />
{isLive && (
<>
<Button
variant="outline"
size="sm"
onClick={syncFromRouter}
disabled={syncBusy !== null}
title="Синхронизация Router → БД"
>
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
</Button>
<Button
variant="outline"
size="sm"
onClick={syncToRouter}
disabled={syncBusy !== null}
title="Синхронизация БД → Router"
>
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void fetchRouterCompare()}
disabled={syncBusy !== null || routerCompareLoading}
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
className="gap-1.5"
>
{routerCompareLoading ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Сверить
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
<FileCodeIcon className="size-4" />RouterOS
</Button>
<Button
variant="outline" size="sm"
onClick={() => setCopyOpen(true)}
disabled={currentRules.length === 0}
title="Копировать правила на другой сервер"
>
<CopyIcon className="size-4" />Копировать
</Button>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
}
</p>
</div>
{/* ── main content ── */}
<div className="flex-1 overflow-y-auto p-6">
/>
}
banner={
<>
{isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</div>
)}
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего правил</span>
<span className="font-semibold tabular-nums">{totalRules}</span>
</div>
{(() => {
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
if (bhTotal === 0) return null
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
{bhTotal} blackhole
</span>
)
})()}
<p className="text-xs text-muted-foreground ml-auto">
{filteredRules.length !== currentRules.length
? `${filteredRules.length} из ${currentRules.length} правил`
: `${currentRules.length} правил`
}
</p>
</div>
</>
}
>
<div className="flex flex-col gap-4">
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
@@ -1817,7 +1813,7 @@ export default function FiltersPage() {
</DataPageCard>
</div>
</div>
</ServerRailLayout>
<RuleSheet
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
@@ -1858,6 +1854,6 @@ export default function FiltersPage() {
recRoutesByServer={recRoutesByServer}
ensureRecursiveFor={ensureRecursiveRoutes}
/>
</div>
</>
)
}
File diff suppressed because it is too large Load Diff
+108 -64
View File
@@ -14,7 +14,7 @@ import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -26,7 +26,6 @@ import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
import { Flag } from "@/components/flag"
import {
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
@@ -35,6 +34,8 @@ import {
DatabaseIcon,
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── label maps ─────────────────────────────────────────────────────────────
@@ -241,6 +242,7 @@ export default function GrePage() {
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
const [search, setSearch] = useState("")
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [tunnelOpen, setTunnelOpen] = useState(false)
const [poolOpen, setPoolOpen] = useState(false)
@@ -293,6 +295,25 @@ export default function GrePage() {
[isLive, displayTunnels],
)
const greRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
}))
), [displayServers, displayTunnels])
const scopedTunnels = useMemo(() => {
if (selectedServerId === ALL_SERVERS_ID) return displayTunnels
return displayTunnels.filter((t) => t.serverId === selectedServerId)
}, [displayTunnels, selectedServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
@@ -341,7 +362,7 @@ export default function GrePage() {
}, [dataError])
const filtered = useMemo(() => {
return displayTunnels.filter((t) => {
return scopedTunnels.filter((t) => {
if (tabFilter === "up" && t.status !== "up") return false
if (tabFilter === "ipsec" && !t.ipsec) return false
if (tabFilter === "plain" && t.ipsec) return false
@@ -354,16 +375,18 @@ export default function GrePage() {
serverById[t.serverId]?.name.toLowerCase().includes(q)
)
})
}, [tabFilter, search, displayTunnels, serverById])
}, [tabFilter, search, scopedTunnels, serverById])
const upCount = displayTunnels.filter((t) => t.status === "up").length
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length
const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
{ value: "all", label: "Все", count: displayTunnels.length },
{ value: "up", label: "Активные", count: upCount },
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
{ value: "all", label: "Все", count: scopedTunnels.length },
{ value: "up", label: "Активные", count: scopedUpCount },
{ value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
{ value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
]
const greExportCode = useMemo(
@@ -372,39 +395,48 @@ export default function GrePage() {
)
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || dataLoading}
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { void syncJhToDb() }}
disabled={!isLive || syncJhBusy || dataLoading}
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
>
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
JH БД
</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<>
<ServerRailLayout
items={greRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && dataLoading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || dataLoading}
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { void syncJhToDb() }}
disabled={!isLive || syncJhBusy || dataLoading}
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
>
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
JH БД
</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{/* Legacy banner */}
@@ -421,27 +453,39 @@ export default function GrePage() {
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка GRE"
items={[
{
id: "tunnels",
label: "Всего туннелей",
value: displayTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активно",
value: upCount,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ipsec",
label: "Защищены IPsec",
value: ipsecCount,
icon: <LockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "pools",
label: "IP-пулов",
value: displayPools.length,
icon: <DatabaseIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Page tabs */}
<div className="flex items-center gap-1 border-b">
@@ -545,7 +589,7 @@ export default function GrePage() {
</div>
</OpsPanel>
</div>
</div>
</ServerRailLayout>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<CodeExportSheet
@@ -801,6 +845,6 @@ export default function GrePage() {
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</>
)
}
File diff suppressed because it is too large Load Diff
+150 -133
View File
@@ -7,17 +7,20 @@ import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-da
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { toast } from "sonner"
import {
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { Flag } from "@/components/flag"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -707,7 +710,7 @@ function InterfacesTab({
}, [grouped, isLive])
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
const canOptimizeLive = isLive && filterServerId !== "all"
const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID
const uniqueLiveFallbackOpt = useMemo(() => {
const out: Record<string, number> = {}
const byRouter: Record<string, OspfItem[]> = {}
@@ -921,20 +924,33 @@ function NeighborsTab({
return (
<div className="flex flex-col gap-5">
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Всего соседей", value: neighbors.length, color: "" },
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
].map(s => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка соседей OSPF"
items={[
{
id: "neighbors",
label: "Всего соседей",
value: neighbors.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "full",
label: "Full",
value: fullCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-full",
label: "Не Full",
value: neighbors.length - fullCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
variant: neighbors.length - fullCount > 0 ? "warning" : "default",
},
]}
/>
{graphNodes.length > 0 && (
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
@@ -1044,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Сессий BFD", value: sessions.length, color: "" },
{ label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" },
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
].map(s => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка BFD"
items={[
{
id: "sessions",
label: "Сессий BFD",
value: sessions.length,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Up",
value: upCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "down",
label: "Down / Admin",
value: downCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
variant: downCount > 0 ? "destructive" : "default",
},
{
id: "init",
label: "Init / другие",
value: initCount,
icon: <LayersIcon className="size-4" />,
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
variant: initCount > 0 ? "warning" : "default",
},
]}
/>
{sessions.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
@@ -1096,7 +1132,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
export default function OspfPage() {
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
const [filterServerId, setFilterServerId] = useState<string>("all")
const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
@@ -1218,9 +1254,26 @@ export default function OspfPage() {
}, [items, neighbors, bfdSessions])
// ── filtered display data ─────────────────────────────────────────────────────
const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
ospfServers.map((s) => {
const counts = serverCounts[s.id]
const host = s.label.replace(/^mt-/, "")
return {
id: s.id,
name: host,
host,
site: s.site,
country: s.country || undefined,
meta: counts
? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}`
: undefined,
}
})
), [ospfServers, serverCounts])
// Graph always shows full topology (highlight is handled by node click inside tab)
// KPIs reflect the current filter
@@ -1229,91 +1282,45 @@ export default function OspfPage() {
const totalAreas = new Set(displayItems.map(i => i.area)).size
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
actions={
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
}
/>
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div>
{/* ── server filter chips (same pattern as Filters page) ─────────────── */}
{ospfServers.length > 0 && (
<div className="border-b bg-muted/20 px-6 py-2.5 flex items-center gap-2 flex-wrap shrink-0">
{/* "All" chip */}
<button
onClick={() => setFilterServerId("all")}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
filterServerId === "all"
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
Все серверы
<span className={cn(
"tabular-nums font-semibold",
filterServerId === "all" ? "" : "text-foreground/60",
)}>{items.length}</span>
</button>
<div className="w-px h-4 bg-border shrink-0" />
{ospfServers.map(s => {
const counts = serverCounts[s.id]
const active = filterServerId === s.id
return (
<button key={s.id} onClick={() => setFilterServerId(s.id)}
<ServerRailLayout
items={ospfRailItems}
selectedId={filterServerId}
onSelect={setFilterServerId}
showAll
allCount={ospfServers.length}
loading={isLive && loading && ospfServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
</>
}
/>
}
banner={
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{s.country ? <Flag code={s.country} size={12} /> : null}
{s.site && (
<span className={cn(
"inline-block px-1 py-0 rounded text-[9px] font-bold leading-4",
active
? "bg-white/20"
: "bg-muted-foreground/15 text-foreground/70",
)}>{s.site}</span>
)}
<span className="font-mono">{s.label.replace(/^mt-/, "")}</span>
{counts && (
<span className={cn(
"tabular-nums text-[10px]",
active ? "opacity-80" : "text-foreground/50",
)}>
{counts.neighbors}n · {counts.ifaces}i
{counts.bfd > 0 && ` · ${counts.bfd}b`}
</span>
)}
{t.icon}{t.label}
</button>
)
})}
))}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-6">
}
>
<div className="flex flex-col gap-5">
{/* data source banner */}
@@ -1346,21 +1353,32 @@ export default function OspfPage() {
</span>
)}
{/* KPI strip */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Роутеров", value: totalRouters },
{ label: "Интерфейсов", value: totalInterfaces },
{ label: "Зон (Area)", value: totalAreas },
].map(s => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка OSPF"
items={[
{
id: "routers",
label: "Роутеров",
value: totalRouters,
icon: <RouterIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ifaces",
label: "Интерфейсов",
value: totalInterfaces,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зон (Area)",
value: totalAreas,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{activeTab === "interfaces" && (
<InterfacesTab
@@ -1384,7 +1402,6 @@ export default function OspfPage() {
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
</div>
</div>
</div>
</ServerRailLayout>
)
}
+53 -32
View File
@@ -17,6 +17,8 @@ import {
} from "@/components/data-grids/probes-speed-probes-data-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import {
@@ -480,19 +482,25 @@ function ScheduleTab({
setRules,
serverOptions,
tunnelsForServer,
defaultSrc,
}: {
rules: SchedRule[]
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
serverOptions: Server[]
tunnelsForServer: (serverId: string) => GreTunnel[]
defaultSrc?: string
}) {
const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1")
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
const [addTun, setAddTun] = useState("")
const [addType, setAddType] = useState<SchedType>("ping")
const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
useEffect(() => {
if (defaultSrc) setAddSrc(defaultSrc)
}, [defaultSrc])
useEffect(() => {
const list = tunnelsForServer(addSrc)
if (list.length && !list.some(t => t.id === addTun)) {
@@ -630,6 +638,20 @@ export default function ProbesPage() {
return liveServers
}, [isLive, liveServers])
const probeRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
selectable: s.enabled,
}))
), [allServers])
useEffect(() => {
if (!isLive) {
setRosSrcV4(undefined)
@@ -887,24 +909,33 @@ export default function ProbesPage() {
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
running.length > 0
? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort()
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}>
<SquareIcon className="size-4" />Остановить все
</Button>
: undefined
}
/>
<div className="flex-1 overflow-y-auto p-6">
<ServerRailLayout
items={probeRailItems}
selectedId={srcId}
onSelect={setSrcId}
showAll={false}
loading={isLive && liveLoad === "loading" && allServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
<>
<ServerRailMobileButton />
{running.length > 0
? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort()
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}>
<SquareIcon className="size-4" />Остановить все
</Button>
: null}
</>
}
/>
}
>
<div className="flex flex-col gap-4">
{isLive && liveLoad === "error" && (
@@ -916,7 +947,7 @@ export default function ProbesPage() {
{isLive && (
<p className="text-xs text-muted-foreground">
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS резолвер приложения, не MikroTik.
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS резолвер приложения, не MikroTik.
</p>
)}
@@ -945,16 +976,6 @@ export default function ProbesPage() {
{/* main config row */}
<div className="flex flex-wrap items-end gap-3">
{/* source server */}
<div>
<OptionLabel>Источник</OptionLabel>
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
{allServers.filter(s => s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</NativeSelect>
</div>
{/* target — all tools except bandwidth */}
{tool !== "bandwidth" && (
<div className="flex-1 min-w-[140px]">
@@ -1169,13 +1190,13 @@ export default function ProbesPage() {
setRules={setRules}
serverOptions={allServers}
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
defaultSrc={srcId}
/>
)
)}
</div>
</div>
</div>
</div>
</div>
</ServerRailLayout>
)
}
+72 -76
View File
@@ -20,6 +20,8 @@ import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
interface BackendServer {
id: number
@@ -543,6 +545,18 @@ export default function RecursiveRoutesPage() {
}
const currentServer = servers.find(s => s.id === selectedServerId)
const rrRailItems = useMemo<ServerTileItem[]>(() => (
servers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
}))
), [servers])
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return rows
@@ -574,82 +588,64 @@ export default function RecursiveRoutesPage() {
}, [filteredRows])
return (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<PlusIcon className="size-4" />Добавить
</Button>
</>
}
/>
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span>
</div>
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{servers.map((s) => {
const count = s.id === selectedServerId ? rows.length : 0
const active = selectedServerId === s.id
return (
<button key={s.id}
onClick={() => setSelectedServerId(s.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
!s.enabled && !active && "opacity-40",
)}>
<StatusDot status={s.status} />
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
<span className={cn(
"tabular-nums font-semibold",
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
)}>{count}</span>
</button>
)
})}
</div>
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
<>
<ServerRailLayout
items={rrRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll={false}
showCount={false}
loading={isLive && !liveServerListReady}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<PlusIcon className="size-4" />Добавить
</Button>
</>
}
/>
}
banner={
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span>
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
)}
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
}
>
{!isLive ? (
<Frame dense className="w-full">
<FramePanel className="p-6 text-sm text-muted-foreground">
@@ -692,7 +688,7 @@ export default function RecursiveRoutesPage() {
</button>
</DataPageCard>
)}
</div>
</ServerRailLayout>
<RouteSheet
open={sheetOpen}
@@ -702,6 +698,6 @@ export default function RecursiveRoutesPage() {
onClose={() => setSheetOpen(false)}
gateways={gatewayOptions}
/>
</div>
</>
)
}
+23 -29
View File
@@ -10,7 +10,7 @@ import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-op
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -589,32 +589,25 @@ export default function RouteOptimizerPage() {
label: "Home роутеров",
value: homeCount,
sub: `${wanCount} WAN-аплинков`,
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
icon: <MonitorIcon className="size-4" />,
},
{
label: "JumpHost",
value: jh.length,
sub: jhSub,
icon: <ServerIcon className="size-4 text-violet-400" />,
icon: <ServerIcon className="size-4" />,
},
{
label: "Exit Node",
value: ex.length,
sub: exSub,
icon: <NetworkIcon className="size-4 text-emerald-500" />,
icon: <NetworkIcon className="size-4" />,
},
{
label: "Переключений",
value: totalSwitches,
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
icon: (
<ZapIcon
className={cn(
"size-4",
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
)}
/>
),
icon: <ZapIcon className="size-4" />,
},
]
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
@@ -762,23 +755,24 @@ export default function RouteOptimizerPage() {
)}
</div>
{/* Stats chips */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{statsChips.map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-xl leading-none font-bold tabular-nums">{s.value}</p>
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка оптимизатора"
items={statsChips.map((s, i) => ({
id: `ro-${i}`,
label: s.label,
value: s.value,
hint: s.sub,
icon: s.icon,
iconClassName: s.label === "Переключений" && totalSwitches > 0
? "text-warning"
: s.label === "Exit Node"
? "text-success"
: s.label === "JumpHost"
? "text-primary"
: "text-muted-foreground",
variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const,
}))}
/>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
+34 -23
View File
@@ -27,8 +27,7 @@ import {
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -491,27 +490,39 @@ export default function ServersPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4" />, iconClass: "text-muted-foreground" },
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4" />, iconClass: "text-[var(--status-online-fg)]" },
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" },
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" },
].map(s => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", s.iconClass)}>
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка серверов"
items={[
{
id: "all",
label: "Всего серверов",
value: counts.all,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "online",
label: "Онлайн",
value: counts.online,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "jh-en",
label: "JH + Exit Node",
value: counts["jump-host"] + counts["exit-node"],
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "home",
label: "Home Router",
value: counts["home-router"],
icon: <HomeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
{/* Table */}
<DataPageCard>
File diff suppressed because it is too large Load Diff
+107 -107
View File
@@ -4,9 +4,22 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag"
import type { ServerStatus } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
type ServerTileItem,
} from "@/components/server-tile-rail"
import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react"
@@ -492,6 +505,64 @@ export default function TerminalPage() {
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
const railItems = useMemo<ServerTileItem[]>(() => {
return termServers.map((s) => ({
id: s.uid,
name: s.name,
host: s.host,
country: s.country || undefined,
status: (s.status ?? undefined) as ServerStatus | undefined,
enabled: s.enabled,
selectable: s.enabled && s.status !== "offline",
title: [s.name, s.host].filter(Boolean).join(" · "),
}))
}, [termServers])
const handleSelectServer = useCallback((id: string) => {
setSelectedUid(id)
setRefreshKey((k) => k + 1)
}, [])
const railHeaderRight = isLive && !serversLoading
? <Badge variant="success-light" size="xs">LIVE</Badge>
: undefined
function QuickCmds() {
return (
<Frame dense spacing="sm" className="min-h-0 shrink-0">
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
<FrameHeader className="border-b px-3 py-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Быстрые команды
</FrameTitle>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-1.5">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
type="button"
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</ScrollArea>
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
<p> история команд</p>
<p>Ctrl+L очистить экран</p>
{isLive
? <p className="text-info">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>}
</FrameFooter>
</FramePanel>
</Frame>
)
}
function injectCommand(cmd: string) {
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
if (!el) return
@@ -502,108 +573,39 @@ export default function TerminalPage() {
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<Button variant="outline" size="sm" onClick={() => {
setRefreshKey(k => k + 1)
if (isLive) setSelectedUid("")
}}>
<RefreshCwIcon className="size-4" />Переподключить
</Button>
}
/>
<div className="flex-1 overflow-hidden p-6">
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
{/* ── sidebar ── */}
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
{/* server picker */}
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
{isLive && serversLoading && (
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
)}
{isLive && !serversLoading && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
<span className="size-1 rounded-full bg-emerald-400" />LIVE
</span>
)}
</div>
{isLive && !serversLoading && liveServers.length === 0 && (
<p className="text-xs text-muted-foreground px-2.5">
Нет доступных серверов
</p>
)}
<div className="space-y-1">
{termServers.map(s => {
const isOffline = s.status === "offline"
const isSelected = s.uid === selectedUid
return (
<button
key={s.uid}
disabled={isOffline || !s.enabled}
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
className={cn(
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
"flex items-center gap-2",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
)}
>
<span className={cn(
"inline-block size-1.5 rounded-full shrink-0",
s.status === "online" ? "bg-emerald-500" :
s.status === "degraded" ? "bg-amber-400" :
s.status === null ? "bg-sky-400" : "bg-red-500",
)} />
{s.country && <Flag code={s.country} />}
<span className="truncate font-mono">{s.name}</span>
</button>
)
})}
</div>
</div>
{/* quick commands */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Быстрые команды
</p>
<div className="space-y-1">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</div>
{/* hints */}
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
<p> история команд</p>
<p>Ctrl+L очистить экран</p>
{isLive
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>
}
</div>
</div>
{/* ── terminal ── */}
<ServerRailLayout
items={railItems}
selectedId={selected?.uid ?? selectedUid}
onSelect={handleSelectServer}
showAll={false}
showCount={false}
showType={false}
headerRight={railHeaderRight}
loading={serversLoading}
extra={<QuickCmds />}
contentClassName="overflow-hidden p-3 md:p-4"
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => {
setRefreshKey((k) => k + 1)
if (isLive) setSelectedUid("")
}}
>
<RefreshCwIcon className="size-4" />
Переподключить
</Button>
</>
}
/>
}
>
{selected ? (
<Terminal
key={termKey}
@@ -612,12 +614,10 @@ export default function TerminalPage() {
backendUrl={backendUrl}
/>
) : (
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div>
)}
</div>
</div>
</div>
</ServerRailLayout>
)
}
File diff suppressed because it is too large Load Diff
+89 -46
View File
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
@@ -961,29 +961,58 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
</Alert>
)}
{/* ── KPI summary ───────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{ icon: <ServerIcon className="size-4 text-muted-foreground" />, label: String(rows.length), sub: "серверов всего", color: "text-foreground" },
{ icon: <CpuIcon className="size-4" />, label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) },
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
].map(kpi => (
<Frame key={kpi.sub} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
{kpi.icon}
</IconTile>
<div className="min-w-0 flex flex-col gap-0.5">
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка ресурсов"
items={[
{
id: "servers",
label: "Серверов всего",
value: rows.length,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "cpu",
label: "Средний CPU",
value: `${avgCpu}%`,
icon: <CpuIcon className="size-4" />,
iconClassName: avgCpu >= 85 ? "text-destructive" : avgCpu >= 70 ? "text-warning" : "text-success",
variant: avgCpu >= 85 ? "destructive" : avgCpu >= 70 ? "warning" : "default",
},
{
id: "ram",
label: "Средний RAM",
value: `${avgRam}%`,
icon: <HardDriveIcon className="size-4" />,
iconClassName: avgRam >= 85 ? "text-destructive" : avgRam >= 70 ? "text-warning" : "text-success",
variant: avgRam >= 85 ? "destructive" : avgRam >= 70 ? "warning" : "default",
},
{
id: "high-cpu",
label: "CPU > 85%",
value: highCpu,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highCpu > 0 ? "text-destructive" : "text-muted-foreground",
variant: highCpu > 0 ? "destructive" : "default",
},
{
id: "high-ram",
label: "RAM > 85%",
value: highRam,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highRam > 0 ? "text-destructive" : "text-muted-foreground",
variant: highRam > 0 ? "destructive" : "default",
},
{
id: "high-hdd",
label: "Диск > 85%",
value: highHdd,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highHdd > 0 ? "text-warning" : "text-muted-foreground",
variant: highHdd > 0 ? "warning" : "default",
},
]}
/>
{/* ── Table ─────────────────────────────────────────────────────────── */}
<DataPageCard>
@@ -2022,28 +2051,42 @@ export default function UptimePage() {
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0">
{[
{ label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" },
{ label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" },
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
].map(k => (
<Frame key={k.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
<div className="flex items-baseline gap-1">
<span className={cn("text-2xl leading-none font-bold tabular-nums", k.color)}>{k.value}</span>
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
</div>
{runningCnt > 0 && k.label === "Тестов выполнено" && (
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5">
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
</p>
)}
</FramePanel>
</Frame>
))}
<div className="px-6 py-4 border-b bg-muted/10 shrink-0">
<KpiStatGrid
aria-label="Сводка speed-проб"
items={[
{
id: "probes",
label: "Speed-пробы",
value: `${speedProbes.length} шт`,
icon: <ArrowUpDownIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "runs",
label: "Тестов выполнено",
value: `${doneRuns.length} run`,
hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
icon: <PlayIcon className="size-4" />,
iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
variant: runningCnt > 0 ? "warning" : "default",
},
{
id: "max-tx",
label: "Макс TX",
value: maxTx != null ? `${maxTx} Мбит/с` : "—",
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "max-rx",
label: "Макс RX",
value: maxRx != null ? `${maxRx} Мбит/с` : "—",
icon: <ArrowDownIcon className="size-4" />,
iconClassName: "text-success",
},
]}
/>
</div>
)
})()}
+297
View File
@@ -0,0 +1,297 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { UserSheet } from "@/components/users/user-sheet"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { servers as mockServers } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import {
ALL_SECTIONS,
INIT_USERS,
bindingDiffKey,
userInitials,
type AppUser,
type AppUserForm,
type UserServerOption,
} from "@/lib/users"
import { listServers } from "@/shared/api/servers"
import {
createAppUser,
createUserBinding,
deleteAppUser,
deleteUserBinding,
listAppUsers,
updateAppUser,
} from "@/shared/api/users"
import { ApiClientError } from "@/shared/api/http-client"
import { toast } from "sonner"
import {
CableIcon,
PlusIcon,
TrashIcon,
UserCheckIcon,
UsersIcon,
} from "lucide-react"
function toServerOptionsFromMock(): UserServerOption[] {
return mockServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
}))
}
export default function UsersPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<AppUser | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
const loadLive = useCallback(async () => {
setLoading(true)
try {
const [list, srvs] = await Promise.all([
listAppUsers(backendUrl),
listServers(backendUrl),
])
setUsers(list)
setServerOptions(srvs.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
})))
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
} finally {
setLoading(false)
}
}, [backendUrl])
useEffect(() => {
if (!isLive) {
setUsers(INIT_USERS)
setServerOptions(toServerOptionsFromMock())
return
}
void loadLive()
}, [isLive, loadLive])
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
const activeCount = users.filter((u) => u.active).length
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
const nextKeys = new Set(next.map(bindingDiffKey))
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
for (const b of prev) {
if (!nextKeys.has(bindingDiffKey(b))) {
await deleteUserBinding(backendUrl, userId, b.id)
}
}
for (const b of next) {
if (!prevKeys.has(bindingDiffKey(b))) {
await createUserBinding(backendUrl, userId, {
serverId: Number(b.serverId),
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey,
peerName: b.peerName,
comment: b.comment,
})
}
}
}
const handleSave = async (form: AppUserForm) => {
if (!isLive) {
if (!editUser) {
const id = `u${Date.now()}`
const created: AppUser = {
id,
...form,
last: "только что",
avatar: userInitials(form.name),
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
}
setUsers((prev) => [...prev, created])
} else {
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
return
}
setSaving(true)
try {
if (!editUser) {
const created = await createAppUser(backendUrl, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(created.id, form.bindings, [])
await loadLive()
} else {
await updateAppUser(backendUrl, editUser.id, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
await loadLive()
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
} catch (err) {
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
toast.error(msg)
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteTarget) return
if (!isLive) {
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
setDeleteTarget(null)
toast.success("Пользователь удалён")
return
}
try {
await deleteAppUser(backendUrl, deleteTarget.id)
setDeleteTarget(null)
await loadLive()
toast.success("Пользователь удалён")
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
}
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
actions={
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
<PlusIcon className="size-4" />Пригласить
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
<KpiStatGrid
aria-label="Сводка пользователей"
items={[
{
id: "all",
label: "Пользователи",
value: users.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "active",
label: "Активные",
value: activeCount,
icon: <UserCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ifaces",
label: "Привязанные ifaces",
value: boundCount,
icon: <CableIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
<DataPageCard>
<UsersDataGrid
users={users}
serversCount={serverOptions.length}
allSectionsCount={ALL_SECTIONS.length}
isLoading={loading}
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
onDelete={setDeleteTarget}
/>
</DataPageCard>
</div>
<UserSheet
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen}
user={editUser}
users={users}
servers={serverOptions}
isLive={isLive}
backendUrl={backendUrl}
saving={saving}
onSave={(f) => { void handleSave(f) }}
onClose={() => setSheetOpen(false)}
/>
{deleteTarget && (
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<TrashIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
}
+34 -24
View File
@@ -4,14 +4,12 @@ import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
} from "lucide-react"
@@ -119,27 +117,39 @@ export default function VxlanPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка VXLAN"
items={[
{
id: "tunnels",
label: "Туннелей",
value: vxlanTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных",
value: upCount,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "vni",
label: "Уникальных VNI",
value: vnis,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "servers",
label: "Серверов",
value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
+410 -169
View File
@@ -10,6 +10,10 @@ import {
WireguardDataGrid,
type WgIfaceWithServer,
} from "@/components/data-grids/wireguard-data-grid"
import {
WireguardPeersGrid,
type WgPeerRow,
} from "@/components/data-grids/wireguard-peers-grid"
import { Button } from "@/components/ui/button"
import {
Alert,
@@ -18,6 +22,18 @@ import {
} from "@/components/reui/alert"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import {
@@ -35,12 +51,25 @@ import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
ALL_SERVERS_ID,
type ServerTileItem,
} from "@/components/server-tile-rail"
import { toast } from "sonner"
import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
Trash2Icon, CodeXmlIcon, AlertCircleIcon,
} from "lucide-react"
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
type WgStatusFilter = "all" | "up" | "down"
type WgFailure = { serverId: string; serverName?: string; error: string }
type PendingDelete =
| { kind: "iface"; iface: WgIfaceWithServer }
| { kind: "peer"; iface: WgIfaceWithServer; peerId: string }
function collectMockInterfaces(): WgIfaceWithServer[] {
const result: WgIfaceWithServer[] = []
for (const srv of mockServers) {
@@ -98,7 +127,11 @@ interface BackendServer {
name: string
host: string
country: string
type?: Server["type"]
enabled: boolean
status?: Server["status"]
latency?: number | null
asn?: string
}
function mapBackendServer(s: BackendServer): Server {
@@ -110,11 +143,11 @@ function mapBackendServer(s: BackendServer): Server {
os: "—",
site: "",
country: s.country || "UN",
asn: "",
type: "exit-node",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: "online",
latency: null,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
@@ -130,15 +163,23 @@ function parseEndpoint(endpoint: string): { address?: string; port?: number } {
}
}
function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][number], index: number): string {
return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}`
}
export default function WireGuardPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [failures, setFailures] = useState<WgFailure[]>([])
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState(false)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
@@ -146,6 +187,7 @@ export default function WireGuardPage() {
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const [liveExport, setLiveExport] = useState<{
rsc?: string
conf?: string
@@ -163,6 +205,7 @@ export default function WireGuardPage() {
])
setLiveIfaces(wg.interfaces.map(dtoToRow))
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
setFailures(wg.failures ?? [])
if (wg.failures?.length) {
toast.warning(
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
@@ -171,6 +214,7 @@ export default function WireGuardPage() {
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
setLiveIfaces([])
setFailures([])
} finally {
setLoading(false)
}
@@ -181,6 +225,7 @@ export default function WireGuardPage() {
queueMicrotask(() => {
setLiveIfaces([])
setLiveServers([])
setFailures([])
})
return
}
@@ -192,10 +237,35 @@ export default function WireGuardPage() {
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const filtered = useMemo(() => {
if (!search) return displayIfaces
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const scopedIfaces = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
}, [displayIfaces, effectiveServerId])
const statusCounts = useMemo(() => {
let up = 0
let down = 0
for (const iface of scopedIfaces) {
if (iface.status === "up") up += 1
else down += 1
}
return { all: scopedIfaces.length, up, down }
}, [scopedIfaces])
const statusFiltered = useMemo(() => {
if (statusFilter === "all") return scopedIfaces
return scopedIfaces.filter((i) => i.status === statusFilter)
}, [scopedIfaces, statusFilter])
const filteredIfaces = useMemo(() => {
if (!search) return statusFiltered
const q = search.toLowerCase()
return displayIfaces.filter(
return statusFiltered.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.serverName.toLowerCase().includes(q) ||
@@ -205,14 +275,47 @@ export default function WireGuardPage() {
(p.endpoint ?? "").includes(q),
),
)
}, [displayIfaces, search])
}, [statusFiltered, search])
const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = displayIfaces.reduce(
const peerRows = useMemo<WgPeerRow[]>(() => {
const q = search.toLowerCase()
const rows: WgPeerRow[] = []
for (const iface of scopedIfaces) {
iface.peers.forEach((peer, index) => {
const id = peerRowId(iface, peer, index)
if (q) {
const hay = [
peer.publicKey,
peer.name ?? "",
peer.endpoint ?? "",
peer.allowedIps.join(" "),
iface.name,
iface.serverName,
].join(" ").toLowerCase()
if (!hay.includes(q)) return
}
rows.push({
...peer,
id,
ifaceId: iface.id,
ifaceName: iface.name,
serverId: iface.serverId,
serverName: iface.serverName,
serverCountry: iface.serverCountry,
})
})
}
return rows
}, [scopedIfaces, search])
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = scopedIfaces.reduce(
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
0,
)
const upIfaces = displayIfaces.filter((i) => i.status === "up").length
const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
const compactServer = effectiveServerId !== ALL_SERVERS_ID
const sheetServerId = compactServer ? effectiveServerId : undefined
const serverOptions = displayServers.map((s) => ({
id: s.id,
@@ -220,6 +323,25 @@ export default function WireGuardPage() {
host: s.host,
}))
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const iface of displayIfaces) {
counts.set(iface.serverId, (counts.get(iface.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
count: counts.get(s.id) ?? 0,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, displayIfaces])
async function handleCreate(form: WgCreateFormState) {
if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме")
@@ -308,15 +430,26 @@ export default function WireGuardPage() {
}
}
async function handleDelete(iface: WgIfaceWithServer) {
if (!isLive || !iface.rosId) {
async function confirmDelete() {
if (!pendingDelete) return
if (!isLive) {
toast.info("Доступно только в live-режиме")
setPendingDelete(null)
return
}
if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return
try {
await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId)
toast.success("Удалено")
if (pendingDelete.kind === "iface") {
if (!pendingDelete.iface.rosId) {
toast.info("Доступно только в live-режиме")
return
}
await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
toast.success("Удалено")
} else {
await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId)
toast.success("Пир удалён")
}
setPendingDelete(null)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка удаления")
@@ -354,21 +487,6 @@ export default function WireGuardPage() {
}
}
async function handleDeletePeer(iface: WgIfaceWithServer, peerId: string) {
if (!isLive) {
toast.info("Доступно только в live-режиме")
return
}
if (!window.confirm("Удалить пира?")) return
try {
await deleteWireGuardPeer(backendUrl, iface.serverId, peerId)
toast.success("Пир удалён")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
}
}
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
if (!exportIface || !isLive) return
setExportBusy(true)
@@ -403,152 +521,248 @@ export default function WireGuardPage() {
setExportIface(iface)
}
const emptyCreateAction = (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
)
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={
<>
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
<>
<ServerRailLayout
items={railItems}
selectedId={effectiveServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayIfaces.length}
loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={
<>
<ServerRailMobileButton />
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
<KpiStatGrid
aria-label="Сводка WireGuard"
items={[
{
id: "ifaces",
label: "Интерфейсов",
value: displayIfaces.length,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных (UP)",
value: upIfaces,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "peers",
label: "Всего пиров",
value: totalPeers,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Пиров онлайн",
value: `${onlinePeers}/${totalPeers}`,
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<Alert variant="info">
<InfoIcon />
<AlertTitle>WireGuard live-интеграция RouterOS 7.x</AlertTitle>
<AlertDescription>
{isLive
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
</AlertDescription>
</Alert>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filtered.length} интерфейсов`}
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-4">
<KpiStatGrid
aria-label="Сводка WireGuard"
items={[
{
id: "ifaces",
label: "Интерфейсов",
value: scopedIfaces.length,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных (UP)",
value: upIfaces,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "peers",
label: "Всего пиров",
value: totalPeers,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Пиров онлайн",
value: `${onlinePeers}/${totalPeers}`,
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<WireguardDataGrid
interfaces={filtered}
onExport={(iface) => openExport(iface, "rsc")}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={handleDelete}
onDeletePeer={handleDeletePeer}
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
/>
</DataPageCard>
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать интерфейс",
lines: [
"/interface wireguard add \\",
" name=wg0 \\",
" listen-port=13231 \\",
" mtu=1420",
],
},
{
title: "Добавить пира",
lines: [
"/interface wireguard peers add \\",
" interface=wg0 \\",
' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25",
],
},
{
title: "Назначить IP",
lines: [
"/ip address add \\",
" address=10.210.0.1/30 \\",
" interface=wg0",
"",
"# Статус:",
"/interface wireguard print",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
{b.title}
</p>
<pre className="bg-muted rounded-md p-2.5 text-muted-foreground text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
</div>
</div>
{failures.length > 0 ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
<AlertDescription>
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
</AlertDescription>
</Alert>
) : null}
{!isLive ? (
<Alert variant="info">
<InfoIcon />
<AlertTitle>Mock-режим</AlertTitle>
<AlertDescription>
Переключитесь в live в настройках, чтобы применять изменения на MikroTik.
</AlertDescription>
</Alert>
) : null}
<Tabs
value={workspaceTab}
onValueChange={(v) => setWorkspaceTab(v as WgWorkspaceTab)}
className="gap-3"
>
<TabsList variant="line">
<TabsTrigger value="interfaces" className="gap-1.5">
<ShieldCheckIcon className="size-3.5" />
Интерфейсы
</TabsTrigger>
<TabsTrigger value="peers" className="gap-1.5">
<UsersIcon className="size-3.5" />
Пиры
</TabsTrigger>
<TabsTrigger value="cli" className="gap-1.5">
<CodeXmlIcon className="size-3.5" />
CLI
</TabsTrigger>
</TabsList>
<TabsContent value="interfaces" className="mt-0 outline-none">
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filteredIfaces.length} интерфейсов`}
segmented={{
value: statusFilter,
onChange: setStatusFilter,
options: [
{ value: "all", label: "Все", count: statusCounts.all },
{ value: "up", label: "UP", count: statusCounts.up },
{ value: "down", label: "DOWN", count: statusCounts.down },
],
}}
/>
<WireguardDataGrid
interfaces={filteredIfaces}
compactServer={compactServer}
emptyAction={emptyCreateAction}
onExport={(iface) => openExport(iface, "rsc")}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={(iface) => setPendingDelete({ kind: "iface", iface })}
onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
/>
</DataPageCard>
</TabsContent>
<TabsContent value="peers" className="mt-0 outline-none">
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по ключу, IP, endpoint…"
countLabel={`${peerRows.length} пиров`}
/>
<WireguardPeersGrid
peers={peerRows}
compactServer={compactServer}
emptyAction={
scopedIfaces.length === 1 ? (
<Button size="sm" onClick={() => setPeerIface(scopedIfaces[0])}>
<PlusIcon className="size-4" />
Добавить пира
</Button>
) : emptyCreateAction
}
onDeletePeer={(row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
if (!iface) return
setPendingDelete({ kind: "peer", iface, peerId: row.id })
}}
onExportPeer={(row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
if (!iface) return
openExport(iface, "peer", row.id)
}}
/>
</DataPageCard>
</TabsContent>
<TabsContent value="cli" className="mt-0 outline-none">
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
{[
{
title: "Создать интерфейс",
lines: [
"/interface wireguard add \\",
" name=wg0 \\",
" listen-port=13231 \\",
" mtu=1420",
],
},
{
title: "Добавить пира",
lines: [
"/interface wireguard peers add \\",
" interface=wg0 \\",
' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25",
],
},
{
title: "Назначить IP",
lines: [
"/ip address add \\",
" address=10.210.0.1/30 \\",
" interface=wg0",
"",
"# Статус:",
"/interface wireguard print",
],
},
].map((b) => (
<div key={b.title}>
<p className="mb-1.5 font-sans text-[11px] font-semibold uppercase tracking-wide text-foreground/80">
{b.title}
</p>
<pre className="overflow-x-auto rounded-md bg-muted p-2.5 text-[11px] leading-relaxed text-muted-foreground">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
</TabsContent>
</Tabs>
</div>
</ServerRailLayout>
<WgCreateSheet
open={createOpen}
onOpenChange={setCreateOpen}
servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy}
onSubmit={handleCreate}
/>
@@ -556,6 +770,7 @@ export default function WireGuardPage() {
open={importOpen}
onOpenChange={setImportOpen}
servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy}
onImport={handleImport}
/>
@@ -581,6 +796,32 @@ export default function WireGuardPage() {
liveBusy={exportBusy}
onRequestLiveExport={isLive ? handleLiveExport : undefined}
/>
</div>
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.kind === "iface"
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры на роутере.`
: pendingDelete
? `Пир на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => void confirmDelete()}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
+5 -1
View File
@@ -1,5 +1,9 @@
# Path to SQLite database file
# PostgreSQL connection string
DATABASE_URL=postgres://mmapp:mmapp@127.0.0.1:5432/mmapp
# SQLite file used only for one-shot boot ETL (empty PG + existing mikrotik.db)
DATABASE_PATH=./mikrotik.db
# SQLITE_IMPORT_FULL_HISTORY=true
# Port for the Fastify server
PORT=8000
+9 -1
View File
@@ -47,8 +47,16 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist
COPY --from=build /app/backend/package.json ./backend/package.json
COPY --from=build /app/backend/drizzle ./backend/drizzle
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules
RUN mkdir -p /app/data
RUN apt-get update \
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client-18 \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /app/data
EXPOSE 8000
CMD ["node", "backend/dist/index.js"]
+2 -2
View File
@@ -6,9 +6,9 @@ config()
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_PATH ?? "./mikrotik.db",
url: process.env.DATABASE_URL ?? "postgres://mmapp:mmapp@127.0.0.1:5432/mmapp",
},
verbose: true,
strict: true,
-36
View File
@@ -1,36 +0,0 @@
CREATE TABLE `server_snapshots` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`polled_at` text NOT NULL,
`status` text NOT NULL,
`latency_ms` real,
`ros_version` text,
`board_name` text,
`uptime` text,
`cpu_load` integer,
`free_memory` integer,
`total_memory` integer,
`identity_name` text,
`raw_interfaces` text,
`raw_ip_addresses` text,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `servers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text DEFAULT '' NOT NULL,
`host` text NOT NULL,
`port` integer DEFAULT 443 NOT NULL,
`username` text DEFAULT 'admin' NOT NULL,
`password` text DEFAULT '' NOT NULL,
`use_ssl` integer DEFAULT true NOT NULL,
`verify_ssl` integer DEFAULT false NOT NULL,
`type` text DEFAULT 'home-router' NOT NULL,
`site` text DEFAULT '' NOT NULL,
`country` text DEFAULT '' NOT NULL,
`asn` text DEFAULT '' NOT NULL,
`comment` text DEFAULT '' NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL
);
+626
View File
@@ -0,0 +1,626 @@
-- MikrotikManager PostgreSQL 18 schema (not 1:1 SQLite).
-- Applied once at backend boot via src/db/migrate.ts
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS servers (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 443,
username TEXT NOT NULL DEFAULT 'admin',
password TEXT NOT NULL DEFAULT '',
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
type TEXT NOT NULL DEFAULT 'home-router'
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
site TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
lan_subnet TEXT NOT NULL DEFAULT '',
wan_uplinks JSONB NOT NULL DEFAULT '[]'::jsonb,
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS traffic_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 30,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
interval_sec INTEGER NOT NULL DEFAULT 120,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
public_endpoint TEXT NOT NULL DEFAULT '',
host_public_key TEXT NOT NULL DEFAULT '',
host_private_key TEXT NOT NULL DEFAULT '',
hub_server_id BIGINT,
retention_hours INTEGER NOT NULL DEFAULT 24,
top_n INTEGER NOT NULL DEFAULT 200,
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
last_datagram_at TIMESTAMPTZ,
last_exporter_ip TEXT,
last_error TEXT,
packets_received BIGINT NOT NULL DEFAULT 0,
peers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS uptime_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 15,
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS evobgp_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
base_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
bot_token TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS acme_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
cloudflare_api_token TEXT NOT NULL DEFAULT '',
default_zone_id TEXT NOT NULL DEFAULT '',
account_private_key TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 21600,
renew_before_days INTEGER NOT NULL DEFAULT 30,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
hour INTEGER NOT NULL DEFAULT 3,
minute INTEGER NOT NULL DEFAULT 0,
week_day INTEGER NOT NULL DEFAULT 0,
month_day INTEGER NOT NULL DEFAULT 1,
keep_count INTEGER NOT NULL DEFAULT 7,
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
server_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
last_run_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS internet_path_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 300,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TIMESTAMPTZ,
last_duration_ms INTEGER,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
id BIGINT PRIMARY KEY CHECK (id = 1),
last_source_finished_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS data_migration (
id INTEGER PRIMARY KEY CHECK (id = 1),
sqlite_imported_at TIMESTAMPTZ,
sqlite_path TEXT,
sqlite_sha256 TEXT,
report_json JSONB
);
CREATE TABLE IF NOT EXISTS filter_rules (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
community TEXT NOT NULL,
community_name TEXT,
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
gateway TEXT NOT NULL DEFAULT '',
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
CREATE TABLE IF NOT EXISTS recursive_routes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
dst_address TEXT NOT NULL,
gateway TEXT NOT NULL,
distance INTEGER NOT NULL DEFAULT 1,
scope INTEGER,
target_scope INTEGER,
routing_table TEXT NOT NULL DEFAULT 'main',
check_gateway TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
disabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
CREATE TABLE IF NOT EXISTS server_snapshots (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
latency_ms DOUBLE PRECISION,
ros_version TEXT,
board_name TEXT,
uptime TEXT,
cpu_load INTEGER,
free_memory BIGINT,
total_memory BIGINT,
identity_name TEXT,
raw_interfaces JSONB,
raw_ip_addresses JSONB,
PRIMARY KEY (id, polled_at)
) PARTITION BY RANGE (polled_at);
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
CREATE INDEX IF NOT EXISTS idx_server_snapshots_polled_brin ON server_snapshots USING BRIN (polled_at);
CREATE TABLE IF NOT EXISTS traffic_samples (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
interface_name TEXT NOT NULL,
peer_public_key TEXT NOT NULL DEFAULT '',
sampled_at TIMESTAMPTZ NOT NULL,
rx_bytes BIGINT NOT NULL DEFAULT 0,
tx_bytes BIGINT NOT NULL DEFAULT 0,
rx_bps BIGINT NOT NULL DEFAULT 0,
tx_bps BIGINT NOT NULL DEFAULT 0,
running BOOLEAN NOT NULL DEFAULT FALSE,
disabled BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time ON traffic_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_sampled_brin ON traffic_samples USING BRIN (sampled_at);
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
ok BOOLEAN NOT NULL,
latency_ms INTEGER,
error TEXT,
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id ON servers_rest_ping_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_brin ON servers_rest_ping_samples USING BRIN (sampled_at);
CREATE TABLE IF NOT EXISTS flow_buckets (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
src TEXT NOT NULL,
dst TEXT NOT NULL,
proto INTEGER NOT NULL DEFAULT 0,
src_port INTEGER NOT NULL DEFAULT 0,
dst_port INTEGER NOT NULL DEFAULT 0,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
in_iface TEXT NOT NULL DEFAULT '',
out_iface TEXT NOT NULL DEFAULT '',
next_hop TEXT NOT NULL DEFAULT '',
flow_start_ms BIGINT NOT NULL DEFAULT 0,
flow_end_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_buckets_bucket_brin ON flow_buckets USING BRIN (bucket_at);
CREATE TABLE IF NOT EXISTS flow_minute_stats (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
unique_src INTEGER NOT NULL DEFAULT 0,
unique_dst INTEGER NOT NULL DEFAULT 0,
conversations INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_minute_stats_brin ON flow_minute_stats USING BRIN (bucket_at);
CREATE TABLE IF NOT EXISTS flow_minute_dims (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, dim, key)
) PARTITION BY RANGE (bucket_at);
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_brin ON flow_minute_dims USING BRIN (bucket_at);
CREATE TABLE IF NOT EXISTS flow_daily_dims (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
day DATE NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, day, dim, key)
) PARTITION BY RANGE (day);
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
CREATE TABLE IF NOT EXISTS flow_ip_meta (
prefix TEXT PRIMARY KEY,
asn INTEGER NOT NULL DEFAULT 0,
country TEXT NOT NULL DEFAULT '',
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
holder TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1,
fetched_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS flow_asn_meta (
asn INTEGER PRIMARY KEY,
holder TEXT NOT NULL DEFAULT '',
fetched_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS uptime_probes (
id TEXT PRIMARY KEY,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
target TEXT NOT NULL,
probe_filter TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
interval_sec INTEGER NOT NULL DEFAULT 0,
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
rtt_ms INTEGER,
loss_pct INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time ON uptime_probe_samples(probe_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_brin ON uptime_probe_samples USING BRIN (sampled_at);
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
sampled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
cpu_load INTEGER NOT NULL DEFAULT 0,
free_memory BIGINT NOT NULL DEFAULT 0,
total_memory BIGINT NOT NULL DEFAULT 0,
free_hdd_space BIGINT NOT NULL DEFAULT 0,
total_hdd_space BIGINT NOT NULL DEFAULT 0,
uptime_seconds BIGINT NOT NULL DEFAULT 0,
board_name TEXT NOT NULL DEFAULT '',
ros_version TEXT NOT NULL DEFAULT '',
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time ON uptime_resource_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_brin ON uptime_resource_samples USING BRIN (sampled_at);
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
id TEXT PRIMARY KEY,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
duration_sec INTEGER NOT NULL DEFAULT 10,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_run_at TIMESTAMPTZ,
last_tx_avg_mbps DOUBLE PRECISION,
last_rx_avg_mbps DOUBLE PRECISION,
last_status TEXT CHECK (last_status IN ('done', 'error')),
last_error TEXT,
last_ping_rtt_ms INTEGER,
last_ping_loss_pct INTEGER,
last_ping_at TIMESTAMPTZ,
last_ping_error TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
id TEXT PRIMARY KEY,
probe_id TEXT,
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
src_address TEXT,
dst_address TEXT,
src_interface_address TEXT,
dst_interface_address TEXT,
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
duration_sec INTEGER NOT NULL DEFAULT 10,
tx_avg_mbps DOUBLE PRECISION,
rx_avg_mbps DOUBLE PRECISION,
ping_rtt_ms INTEGER,
ping_loss_pct INTEGER,
ping_error TEXT,
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
cert_name TEXT NOT NULL,
domain_names JSONB NOT NULL,
key_type TEXT NOT NULL DEFAULT 'rsa2048',
trust_store TEXT NOT NULL DEFAULT 'www,api',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error TEXT
);
CREATE TABLE IF NOT EXISTS backup_entries (
id TEXT PRIMARY KEY,
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
server_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
CREATE TABLE IF NOT EXISTS alert_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
cooldown_override TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
condition TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
cooldown TEXT NOT NULL DEFAULT '',
rule_chat_id TEXT NOT NULL DEFAULT '',
confirm_stability_sec INTEGER,
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
recovery_stability_sec INTEGER,
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_rule_targets (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
target TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
condition_line TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
CREATE TABLE IF NOT EXISTS alert_engine_state (
scope_key TEXT PRIMARY KEY,
last_fired_at TEXT NOT NULL DEFAULT '',
last_payload_hash TEXT
);
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
rule_id TEXT PRIMARY KEY,
payload_hash TEXT NOT NULL,
since_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS alert_history (
id TEXT PRIMARY KEY,
rule_id TEXT,
group_id TEXT,
rule_name TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
message TEXT NOT NULL,
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
fired_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
CREATE TABLE IF NOT EXISTS alert_outbox (
id TEXT PRIMARY KEY,
dedupe_key TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
next_attempt_at TIMESTAMPTZ NOT NULL,
payload_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
last_error TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
CREATE TABLE IF NOT EXISTS scheduler_runs (
id TEXT PRIMARY KEY,
job_key TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
error TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0,
result_json JSONB
);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
event_type TEXT NOT NULL,
source_module TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
payload_json JSONB
);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
CREATE TABLE IF NOT EXISTS app_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
active BOOLEAN NOT NULL DEFAULT TRUE,
avatar TEXT NOT NULL DEFAULT '',
last_seen TIMESTAMPTZ,
sections_json JSONB NOT NULL DEFAULT '[]'::jsonb,
servers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (server_id, interface_name, peer_public_key)
);
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
sampled_at TIMESTAMPTZ NOT NULL,
payload_json JSONB NOT NULL,
PRIMARY KEY (id, sampled_at)
) PARTITION BY RANGE (sampled_at);
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_brin ON internet_path_snapshots USING BRIN (sampled_at);
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
+4 -4
View File
@@ -1,13 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "6",
"version": "7",
"when": 1777572014210,
"tag": "0000_living_xorn",
"tag": "0000_postgresql",
"breakpoints": true
}
]
}
}
+10 -2
View File
@@ -1,7 +1,7 @@
{
"name": "mikrotik-manager-backend",
"version": "1.0.0",
"description": "MikroTik Manager backend — Fastify + Drizzle + SQLite",
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
@@ -11,8 +11,14 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts"
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/pg-schema.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
@@ -25,12 +31,14 @@
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"pg": "^8.23.0",
"undici": "^8.1.0",
"zod": "^4.4.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"@types/pg": "^8.23.1",
"drizzle-kit": "^0.31.10",
"jose": "^6.2.11",
"pino-pretty": "^13.1.3",
+39
View File
@@ -0,0 +1,39 @@
import Database from "better-sqlite3"
import fs from "node:fs"
const path = process.argv[2] ?? "./mikrotik.db"
if (!fs.existsSync(path)) {
console.error("NO_SQLITE", path)
process.exit(2)
}
const st = fs.statSync(path)
const db = new Database(path, { readonly: true, fileMustExist: true })
const tables = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
.all()
console.log("FILE_BYTES", st.size)
console.log("TABLES", tables.length)
for (const t of tables) {
const c = db.prepare(`SELECT COUNT(*) AS c FROM "${t.name}"`).get()
console.log(`COUNT\t${t.name}\t${c.c}`)
}
try {
const d = db.prepare(`SELECT COUNT(*) AS c FROM (
SELECT dedupe_key FROM alert_outbox GROUP BY dedupe_key HAVING COUNT(*) > 1
)`).get()
console.log("DUP_DEDUPE_KEYS", d.c)
} catch (e) {
console.log("DUP_DEDUPE_ERR", e instanceof Error ? e.message : e)
}
for (const [label, sql] of [
["ORPHAN_FLOW_MINUTE", `SELECT COUNT(*) c FROM flow_minute_stats s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
["ORPHAN_FLOW_BUCKETS", `SELECT COUNT(*) c FROM flow_buckets s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
["ORPHAN_BINDINGS", `SELECT COUNT(*) c FROM user_interface_bindings s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
]) {
try {
console.log(label, db.prepare(sql).get().c)
} catch (e) {
console.log(label + "_ERR", e instanceof Error ? e.message : e)
}
}
db.close()
+6
View File
@@ -11,7 +11,9 @@ function boolEnv(v: string | undefined, fallback: boolean): boolean {
const isProd = process.env.NODE_ENV === "production"
const envSchema = z.object({
DATABASE_URL: z.string().default("postgres://mmapp:mmapp@127.0.0.1:5432/mmapp"),
DATABASE_PATH: z.string().default("./mikrotik.db"),
SQLITE_IMPORT_FULL_HISTORY: z.boolean().default(false),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
AUTH_REQUIRED: z.boolean().default(false),
@@ -21,7 +23,9 @@ const envSchema = z.object({
})
const raw = {
DATABASE_URL: process.env.DATABASE_URL,
DATABASE_PATH: process.env.DATABASE_PATH,
SQLITE_IMPORT_FULL_HISTORY: boolEnv(process.env.SQLITE_IMPORT_FULL_HISTORY, false),
PORT: process.env.PORT,
CORS_ORIGIN: process.env.CORS_ORIGIN,
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
@@ -53,7 +57,9 @@ if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
}
export const env = {
DATABASE_URL: parsed.data.DATABASE_URL,
DATABASE_PATH: parsed.data.DATABASE_PATH,
sqliteImportFullHistory: parsed.data.SQLITE_IMPORT_FULL_HISTORY,
PORT: parsed.data.PORT,
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
authRequired: parsed.data.AUTH_REQUIRED,
+33
View File
@@ -0,0 +1,33 @@
import { pool } from "./index.js"
import { applySqlMigrations } from "./migrate.js"
import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js"
import { importSqliteToPostgres, shouldImportSqlite } from "./sqlite-import.js"
import { env } from "../config.js"
const ETL_LOCK = 8723101
export async function initDatabase(): Promise<void> {
await pool.query("SELECT 1")
await applySqlMigrations(pool)
await ensurePartitionsAround(pool)
const client = await pool.connect()
try {
await client.query("SELECT pg_advisory_lock($1)", [ETL_LOCK])
if (await shouldImportSqlite(pool, env.DATABASE_PATH)) {
console.log(`SQLite → PostgreSQL: импорт ${env.DATABASE_PATH}`)
const report = await importSqliteToPostgres(pool, env.DATABASE_PATH, { strict: true })
console.log(
`SQLite → PostgreSQL: готово за ${report.durationMs}ms, таблиц ${Object.keys(report.tables).length}`,
)
}
} finally {
try {
await client.query("SELECT pg_advisory_unlock($1)", [ETL_LOCK])
} catch {
/* ignore */
}
client.release()
}
await dropExpiredPartitions(pool)
await ensurePartitionsAround(pool)
}
+52 -706
View File
@@ -1,725 +1,71 @@
import Database from "better-sqlite3"
import { existsSync, readFileSync } from "node:fs"
import path from "node:path"
type SqliteHandle = InstanceType<typeof Database>
import { drizzle } from "drizzle-orm/better-sqlite3"
import { isMainThread } from "node:worker_threads"
import pg from "pg"
import { drizzle } from "drizzle-orm/node-postgres"
import { env } from "../config.js"
import * as schema from "./schema.js"
import { bindSql } from "./sql-bind.js"
const sqlite = new Database(env.DATABASE_PATH)
const INT8_OID = 20
const DATE_OID = 1082
// WAL mode for better concurrent read performance
sqlite.pragma("journal_mode = WAL")
sqlite.pragma("foreign_keys = ON")
sqlite.exec(`
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 443,
username TEXT NOT NULL DEFAULT 'admin',
password TEXT NOT NULL DEFAULT '',
use_ssl INTEGER NOT NULL DEFAULT 1,
verify_ssl INTEGER NOT NULL DEFAULT 0,
type TEXT NOT NULL DEFAULT 'home-router',
site TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
lan_subnet TEXT NOT NULL DEFAULT '',
wan_uplinks TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
pg.types.setTypeParser(INT8_OID, (val) => {
const n = Number(val)
return Number.isSafeInteger(n) ? n : val
})
pg.types.setTypeParser(DATE_OID, (val) => val)
CREATE TABLE IF NOT EXISTS server_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
polled_at TEXT NOT NULL,
status TEXT NOT NULL,
latency_ms REAL,
ros_version TEXT,
board_name TEXT,
uptime TEXT,
cpu_load INTEGER,
free_memory INTEGER,
total_memory INTEGER,
identity_name TEXT,
raw_interfaces TEXT,
raw_ip_addresses TEXT,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS filter_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
community TEXT NOT NULL,
community_name TEXT,
action TEXT NOT NULL DEFAULT 'route',
gateway TEXT NOT NULL DEFAULT '',
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort
ON filter_rules(server_id, sort_order);
CREATE TABLE IF NOT EXISTS recursive_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
dst_address TEXT NOT NULL,
gateway TEXT NOT NULL,
distance INTEGER NOT NULL DEFAULT 1,
scope INTEGER,
target_scope INTEGER,
routing_table TEXT NOT NULL DEFAULT 'main',
check_gateway TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort
ON recursive_routes(server_id, sort_order);
CREATE TABLE IF NOT EXISTS traffic_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 30,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS traffic_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
sampled_at TEXT NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
rx_bps INTEGER NOT NULL DEFAULT 0,
tx_bps INTEGER NOT NULL DEFAULT 0,
running INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
ON traffic_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
ON traffic_samples(server_id, interface_name, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 15,
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS uptime_probes (
id TEXT PRIMARY KEY,
src_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
target TEXT NOT NULL,
probe_filter TEXT NOT NULL DEFAULT '—',
enabled INTEGER NOT NULL DEFAULT 1,
show_on_dashboard INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort
ON uptime_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
probe_id TEXT NOT NULL,
sampled_at TEXT NOT NULL,
rtt_ms INTEGER,
loss_pct INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'down',
FOREIGN KEY (probe_id) REFERENCES uptime_probes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time
ON uptime_probe_samples(probe_id, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sampled_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'offline',
cpu_load INTEGER NOT NULL DEFAULT 0,
free_memory INTEGER NOT NULL DEFAULT 0,
total_memory INTEGER NOT NULL DEFAULT 0,
free_hdd_space INTEGER NOT NULL DEFAULT 0,
total_hdd_space INTEGER NOT NULL DEFAULT 0,
uptime_seconds INTEGER NOT NULL DEFAULT 0,
board_name TEXT NOT NULL DEFAULT '',
ros_version TEXT NOT NULL DEFAULT '',
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time
ON uptime_resource_samples(server_id, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
id TEXT PRIMARY KEY,
src_server_id INTEGER NOT NULL,
dst_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tcp',
direction TEXT NOT NULL DEFAULT 'both',
duration_sec INTEGER NOT NULL DEFAULT 10,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at TEXT,
last_tx_avg_mbps REAL,
last_rx_avg_mbps REAL,
last_status TEXT,
last_error TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort
ON uptime_speed_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
id TEXT PRIMARY KEY,
probe_id TEXT,
src_server_id INTEGER NOT NULL,
dst_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
dst_interface TEXT NOT NULL DEFAULT '',
src_address TEXT,
dst_address TEXT,
src_interface_address TEXT,
dst_interface_address TEXT,
protocol TEXT NOT NULL DEFAULT 'tcp',
direction TEXT NOT NULL DEFAULT 'both',
duration_sec INTEGER NOT NULL DEFAULT 10,
tx_avg_mbps REAL,
rx_avg_mbps REAL,
ping_rtt_ms INTEGER,
ping_loss_pct INTEGER,
ping_error TEXT,
status TEXT NOT NULL DEFAULT 'done',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at
ON uptime_speed_test_runs(created_at);
CREATE TABLE IF NOT EXISTS scheduler_runs (
id TEXT PRIMARY KEY,
job_key TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0,
result_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
ON scheduler_runs(job_key, finished_at);
CREATE TABLE IF NOT EXISTS internet_path_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 300,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled
ON internet_path_snapshots(sampled_at DESC);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
level TEXT NOT NULL,
event_type TEXT NOT NULL,
source_module TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
payload_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
interval_sec INTEGER NOT NULL DEFAULT 120,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sampled_at TEXT NOT NULL,
ok INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER,
error TEXT,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id
ON servers_rest_ping_samples(server_id, id DESC);
CREATE TABLE IF NOT EXISTS alert_gre_tunnel_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
target_label TEXT NOT NULL,
status TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_gre_tunnel_samples_label_id
ON alert_gre_tunnel_samples(target_label, id DESC);
CREATE TABLE IF NOT EXISTS alert_bgp_peer_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
peer_key TEXT NOT NULL,
state TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_bgp_peer_samples_key_id
ON alert_bgp_peer_samples(peer_key, id DESC);
CREATE TABLE IF NOT EXISTS evobgp_settings (
id INTEGER PRIMARY KEY,
base_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
id INTEGER PRIMARY KEY,
bot_token TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS acme_settings (
id INTEGER PRIMARY KEY,
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
cloudflare_api_token TEXT NOT NULL DEFAULT '',
default_zone_id TEXT NOT NULL DEFAULT '',
account_private_key TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued',
step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual',
server_id TEXT NOT NULL,
cert_name TEXT NOT NULL,
domain_names TEXT NOT NULL,
key_type TEXT NOT NULL DEFAULT 'rsa2048',
trust_store TEXT NOT NULL DEFAULT 'www,api',
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT,
finished_at TEXT,
error TEXT
);
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 21600,
renew_before_days INTEGER NOT NULL DEFAULT 30,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
frequency TEXT NOT NULL DEFAULT 'daily',
hour INTEGER NOT NULL DEFAULT 3,
minute INTEGER NOT NULL DEFAULT 0,
week_day INTEGER NOT NULL DEFAULT 0,
month_day INTEGER NOT NULL DEFAULT 1,
keep_count INTEGER NOT NULL DEFAULT 7,
format TEXT NOT NULL DEFAULT 'rsc',
server_ids_json TEXT NOT NULL DEFAULT '[]',
last_run_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_entries (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
server_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'manual',
notes TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
condition TEXT NOT NULL,
severity TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
cooldown TEXT NOT NULL DEFAULT '5м',
rule_chat_id TEXT NOT NULL DEFAULT '',
recovery_mode TEXT NOT NULL DEFAULT 'always',
recovery_stability_sec INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_history (
id TEXT PRIMARY KEY,
rule_id TEXT,
rule_name TEXT NOT NULL,
severity TEXT NOT NULL,
message TEXT NOT NULL,
sent_ok INTEGER NOT NULL DEFAULT 1,
fired_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
CREATE TABLE IF NOT EXISTS alert_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
combine_mode TEXT NOT NULL DEFAULT 'any',
enabled INTEGER NOT NULL DEFAULT 1,
cooldown_override TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_rule_targets (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL,
target TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL,
condition_line TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
CREATE TABLE IF NOT EXISTS alert_engine_state (
scope_key TEXT PRIMARY KEY,
last_fired_at TEXT NOT NULL DEFAULT '',
last_payload_hash TEXT
);
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
kind TEXT PRIMARY KEY,
payload_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
rule_id TEXT PRIMARY KEY,
payload_hash TEXT NOT NULL,
since_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS alert_destinations (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'telegram',
label TEXT NOT NULL DEFAULT '',
telegram_chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_outbox (
id TEXT PRIMARY KEY,
dedupe_key TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'telegram',
status TEXT NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
next_attempt_at TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
sent_at TEXT,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt
ON alert_outbox(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_dedupe
ON alert_outbox(dedupe_key);
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
id INTEGER PRIMARY KEY,
last_source_finished_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
// Lightweight schema evolution for existing databases without migrations
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
if (!hasCountryColumn) {
sqlite.exec(`ALTER TABLE recursive_routes ADD COLUMN country TEXT NOT NULL DEFAULT ''`)
export function createPool(max = 16): pg.Pool {
return new pg.Pool({
connectionString: env.DATABASE_URL,
max,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 8_000,
statement_timeout: 120_000,
})
}
const uptimeProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_probes')`).all() as Array<{ name?: string }>
const hasSrcInterfaceColumn = uptimeProbeCols.some((c) => c.name === "src_interface")
if (!hasSrcInterfaceColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN src_interface TEXT NOT NULL DEFAULT ''`)
}
const hasShowOnDashboardColumn = uptimeProbeCols.some((c) => c.name === "show_on_dashboard")
if (!hasShowOnDashboardColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 0`)
}
const hasProbeIntervalSecColumn = uptimeProbeCols.some((c) => c.name === "interval_sec")
if (!hasProbeIntervalSecColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN interval_sec INTEGER NOT NULL DEFAULT 0`)
export const pool = createPool(isMainThread ? 16 : 4)
export const db = drizzle(pool, { schema })
export async function dbQuery<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[] | Record<string, unknown>,
): Promise<pg.QueryResult<T>> {
const q = bindSql(text, params)
return pool.query<T>(q)
}
const uptimeSettingsCols = sqlite.prepare(`PRAGMA table_info('uptime_settings')`).all() as Array<{ name?: string }>
const hasProbeIntervalColumn = uptimeSettingsCols.some((c) => c.name === "probe_interval_sec")
if (!hasProbeIntervalColumn) {
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN probe_interval_sec INTEGER NOT NULL DEFAULT 15`)
}
const hasSpeedIntervalColumn = uptimeSettingsCols.some((c) => c.name === "speed_interval_sec")
if (!hasSpeedIntervalColumn) {
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_interval_sec INTEGER NOT NULL DEFAULT 60`)
export async function dbAll<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[] | Record<string, unknown>,
): Promise<T[]> {
const res = await dbQuery<T>(text, params)
return res.rows
}
const hasUptimeJobFlags = uptimeSettingsCols.some((c) => c.name === "resources_enabled")
if (!hasUptimeJobFlags) {
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN resources_enabled INTEGER NOT NULL DEFAULT 1`)
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN ping_enabled INTEGER NOT NULL DEFAULT 1`)
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_enabled INTEGER NOT NULL DEFAULT 1`)
sqlite.exec(
`UPDATE uptime_settings SET resources_enabled = enabled, ping_enabled = enabled, speed_enabled = enabled WHERE id = 1`,
)
export async function dbGet<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[] | Record<string, unknown>,
): Promise<T | undefined> {
const rows = await dbAll<T>(text, params)
return rows[0]
}
const uptimeSpeedProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_speed_probes')`).all() as Array<{ name?: string }>
const ensureSpeedProbeCol = (name: string, ddl: string) => {
if (!uptimeSpeedProbeCols.some((c) => c.name === name)) sqlite.exec(ddl)
}
ensureSpeedProbeCol("last_run_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_run_at TEXT`)
ensureSpeedProbeCol("last_tx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_tx_avg_mbps REAL`)
ensureSpeedProbeCol("last_rx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_rx_avg_mbps REAL`)
ensureSpeedProbeCol("last_status", `ALTER TABLE uptime_speed_probes ADD COLUMN last_status TEXT`)
ensureSpeedProbeCol("last_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_error TEXT`)
ensureSpeedProbeCol("last_ping_rtt_ms", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_rtt_ms INTEGER`)
ensureSpeedProbeCol("last_ping_loss_pct", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_loss_pct INTEGER`)
ensureSpeedProbeCol("last_ping_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_at TEXT`)
ensureSpeedProbeCol("last_ping_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_error TEXT`)
const schedulerRunCols = sqlite.prepare(`PRAGMA table_info('scheduler_runs')`).all() as Array<{ name?: string }>
if (!schedulerRunCols.some((c) => c.name === "result_json")) {
sqlite.exec(`ALTER TABLE scheduler_runs ADD COLUMN result_json TEXT`)
}
const serverCols = sqlite.prepare(`PRAGMA table_info('servers')`).all() as Array<{ name?: string }>
if (!serverCols.some((c) => c.name === "lan_subnet")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN lan_subnet TEXT NOT NULL DEFAULT ''`)
}
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
}
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
sqlite.exec(`ALTER TABLE alert_telegram_settings ADD COLUMN message_thread_id INTEGER`)
}
const alertRulesCols = sqlite.prepare(`PRAGMA table_info('alert_rules')`).all() as Array<{ name?: string }>
if (!alertRulesCols.some((c) => c.name === "group_id")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN group_id TEXT`)
}
if (!alertRulesCols.some((c) => c.name === "confirm_stability_sec")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN confirm_stability_sec INTEGER`)
}
if (!alertRulesCols.some((c) => c.name === "recovery_mode")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_mode TEXT NOT NULL DEFAULT 'always'`)
}
if (!alertRulesCols.some((c) => c.name === "recovery_stability_sec")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_stability_sec INTEGER`)
}
const alertHistoryCols = sqlite.prepare(`PRAGMA table_info('alert_history')`).all() as Array<{ name?: string }>
if (!alertHistoryCols.some((c) => c.name === "group_id")) {
sqlite.exec(`ALTER TABLE alert_history ADD COLUMN group_id TEXT`)
}
/** Одна строка target на правило из legacy-колонки `alert_rules.target` */
sqlite.exec(`
INSERT OR IGNORE INTO alert_rule_targets (id, rule_id, target, sort_index)
SELECT 'rt-' || id || '-0', id, target, 0 FROM alert_rules
WHERE id NOT IN (SELECT rule_id FROM alert_rule_targets)
`)
/** Одна строка условия из legacy `alert_rules.condition` */
sqlite.exec(`
INSERT OR IGNORE INTO alert_rule_conditions (id, rule_id, condition_line, sort_index)
SELECT 'rc-' || id || '-0', id, condition, 0 FROM alert_rules
WHERE id NOT IN (SELECT rule_id FROM alert_rule_conditions)
`)
sqlite.exec(`
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 30, 14
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14
WHERE NOT EXISTS (SELECT 1 FROM uptime_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO evobgp_settings (id, base_url, api_key, enabled)
SELECT 1, '', '', 0
WHERE NOT EXISTS (SELECT 1 FROM evobgp_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO servers_api_ping_settings (id, enabled, interval_sec)
SELECT 1, 0, 120
WHERE NOT EXISTS (SELECT 1 FROM servers_api_ping_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO internet_path_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 300, 14
WHERE NOT EXISTS (SELECT 1 FROM internet_path_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_telegram_settings (id, bot_token, chat_id)
SELECT 1, '', ''
WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO acme_settings (id, directory_url, cloudflare_api_token, default_zone_id, account_private_key)
SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
`)
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
if (!certIssueJobCols.some((c) => c.name === "source")) {
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
}
sqlite.exec(`
INSERT INTO certificate_renew_settings (id, enabled, interval_sec, renew_before_days)
SELECT 1, 1, 21600, 30
WHERE NOT EXISTS (SELECT 1 FROM certificate_renew_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO backup_schedule_settings (id, enabled, frequency, hour, minute, week_day, month_day, keep_count, format, server_ids_json)
SELECT 1, 1, 'daily', 3, 0, 0, 1, 7, 'rsc', '[]'
WHERE NOT EXISTS (SELECT 1 FROM backup_schedule_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
SELECT 1, NULL
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
`)
const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number }
if (backupEntryCount.c === 0) {
const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json")
if (existsSync(legacyIndexPath)) {
export async function withAdvisoryLock<T>(key: number, fn: () => Promise<T>): Promise<T> {
const client = await pool.connect()
try {
await client.query("SELECT pg_advisory_lock($1)", [key])
return await fn()
} finally {
try {
const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown
if (Array.isArray(parsed)) {
const insert = sqlite.prepare(`
INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at)
VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt)
`)
for (const row of parsed) {
if (!row || typeof row !== "object") continue
const item = row as Record<string, unknown>
const id = String(item.id ?? "").trim()
const filename = String(item.filename ?? "").trim()
if (!id || !filename) continue
insert.run({
id,
serverId: String(item.serverId ?? ""),
serverName: String(item.serverName ?? ""),
filename,
sizeBytes: Number(item.sizeBytes ?? 0) || 0,
kind: item.kind === "auto" ? "auto" : "manual",
notes: item.notes == null ? null : String(item.notes),
createdAt: String(item.createdAt ?? new Date().toISOString()),
})
}
}
await client.query("SELECT pg_advisory_unlock($1)", [key])
} catch {
/* legacy index.json не читается — пропускаем */
/* ignore */
}
client.release()
}
}
export const db = drizzle(sqlite, { schema })
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
export const sqliteDatabase: SqliteHandle = sqlite
export async function closePool(): Promise<void> {
await pool.end()
}
+43
View File
@@ -0,0 +1,43 @@
/** JSONB from node-pg may already be an object; SQLite leftovers may be strings. */
export function parseJsonObject(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value === "string" && value.trim()) {
try {
const parsed = JSON.parse(value) as unknown
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
/* ignore */
}
}
return {}
}
export function parseJsonArray(value: unknown): unknown[] {
if (Array.isArray(value)) return value
if (typeof value === "string" && value.trim()) {
try {
const parsed = JSON.parse(value) as unknown
if (Array.isArray(parsed)) return parsed
} catch {
/* ignore */
}
}
return []
}
export function toJsonb(value: unknown): unknown {
if (value == null) return null
if (typeof value === "string") {
try {
return JSON.parse(value) as unknown
} catch {
return value
}
}
return value
}
+43
View File
@@ -0,0 +1,43 @@
import { readFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import type { Pool } from "pg"
const MIGRATION_ID = "0000_postgresql"
function migrationsDir(): string {
const here = dirname(fileURLToPath(import.meta.url))
const candidates = [
join(here, "..", "..", "drizzle"),
join(process.cwd(), "drizzle"),
join(process.cwd(), "backend", "drizzle"),
]
for (const dir of candidates) {
try {
readFileSync(join(dir, `${MIGRATION_ID}.sql`), "utf8")
return dir
} catch {
/* try next */
}
}
throw new Error("Не найден backend/drizzle/0000_postgresql.sql")
}
export async function applySqlMigrations(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`)
const { rows } = await pool.query<{ id: string }>(
`SELECT id FROM schema_migrations WHERE id = $1`,
[MIGRATION_ID],
)
if (rows.length > 0) return
const sql = readFileSync(join(migrationsDir(), `${MIGRATION_ID}.sql`), "utf8")
await pool.query(sql)
await pool.query(`INSERT INTO schema_migrations (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, [
MIGRATION_ID,
])
}
+129
View File
@@ -0,0 +1,129 @@
import type { Pool } from "pg"
export type PartitionKind = "day" | "week" | "month"
export interface PartitionSpec {
parent: string
kind: PartitionKind
keepDays: number
}
export const PARTITION_SPECS: PartitionSpec[] = [
{ parent: "flow_buckets", kind: "day", keepDays: 4 },
{ parent: "flow_minute_stats", kind: "day", keepDays: 4 },
{ parent: "flow_minute_dims", kind: "day", keepDays: 4 },
{ parent: "flow_daily_dims", kind: "month", keepDays: 420 },
{ parent: "traffic_samples", kind: "week", keepDays: 21 },
{ parent: "servers_rest_ping_samples", kind: "week", keepDays: 35 },
{ parent: "uptime_probe_samples", kind: "week", keepDays: 21 },
{ parent: "uptime_resource_samples", kind: "week", keepDays: 21 },
{ parent: "server_snapshots", kind: "week", keepDays: 21 },
{ parent: "internet_path_snapshots", kind: "week", keepDays: 21 },
]
function utcDate(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
}
function addUtcDays(d: Date, n: number): Date {
const x = utcDate(d)
x.setUTCDate(x.getUTCDate() + n)
return x
}
function startOfWeekUtc(d: Date): Date {
const x = utcDate(d)
const day = x.getUTCDay() || 7
x.setUTCDate(x.getUTCDate() - (day - 1))
return x
}
function startOfMonthUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1))
}
function fmtDay(d: Date): string {
return d.toISOString().slice(0, 10)
}
function partName(parent: string, kind: PartitionKind, start: Date): string {
if (kind === "day") return `${parent}_d_${fmtDay(start).replaceAll("-", "")}`
if (kind === "week") return `${parent}_w_${fmtDay(start).replaceAll("-", "")}`
return `${parent}_m_${start.getUTCFullYear()}${String(start.getUTCMonth() + 1).padStart(2, "0")}`
}
function rangeFor(kind: PartitionKind, ts: Date): { start: Date; end: Date } {
if (kind === "day") {
const start = utcDate(ts)
return { start, end: addUtcDays(start, 1) }
}
if (kind === "week") {
const start = startOfWeekUtc(ts)
return { start, end: addUtcDays(start, 7) }
}
const start = startOfMonthUtc(ts)
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1))
return { start, end }
}
export async function ensurePartitionFor(
pool: Pool,
parent: string,
kind: PartitionKind,
ts: Date,
): Promise<string> {
const { start, end } = rangeFor(kind, ts)
const name = partName(parent, kind, start)
const from = fmtDay(start)
const to = fmtDay(end)
await pool.query(
`CREATE TABLE IF NOT EXISTS ${name} PARTITION OF ${parent} FOR VALUES FROM ('${from}') TO ('${to}')`,
)
return name
}
export async function ensurePartitionsAround(pool: Pool, around = new Date()): Promise<void> {
for (const spec of PARTITION_SPECS) {
const daysAhead = spec.kind === "month" ? 40 : spec.kind === "week" ? 21 : 8
const start = addUtcDays(around, -spec.keepDays)
const end = addUtcDays(around, daysAhead)
for (let t = new Date(start.getTime()); t < end; ) {
await ensurePartitionFor(pool, spec.parent, spec.kind, t)
if (spec.kind === "day") t = addUtcDays(t, 1)
else if (spec.kind === "week") t = addUtcDays(t, 7)
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
}
}
}
export async function dropExpiredPartitions(pool: Pool, around = new Date()): Promise<void> {
for (const spec of PARTITION_SPECS) {
const cutoff = addUtcDays(around, -spec.keepDays)
const { rows } = await pool.query<{ relname: string }>(
`SELECT c.relname
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = $1`,
[spec.parent],
)
for (const row of rows) {
const m = row.relname.match(/_([dwm])_(\d{8}|\d{6})$/)
if (!m) continue
const stamp = m[2]
let start: Date
if (stamp.length === 6) {
start = new Date(Date.UTC(Number(stamp.slice(0, 4)), Number(stamp.slice(4, 6)) - 1, 1))
} else {
start = new Date(`${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T00:00:00Z`)
}
if (start < cutoff) {
await pool.query(`DROP TABLE IF EXISTS ${row.relname}`)
}
}
}
}
export function specForParent(parent: string): PartitionSpec | undefined {
return PARTITION_SPECS.find((s) => s.parent === parent)
}
+56
View File
@@ -0,0 +1,56 @@
import assert from "node:assert/strict"
import { withPgOrSkip } from "../test/pg.js"
import { dbQuery, pool } from "./index.js"
import { ensurePartitionFor } from "./partitions.js"
if (!(await withPgOrSkip())) {
console.log("pg-schema.test.ts: skip")
process.exit(0)
}
{
const { rows } = await dbQuery<{ n: string }>(`SELECT COUNT(*)::text AS n FROM servers`)
assert.ok(rows[0])
}
{
await dbQuery(`
INSERT INTO servers (name, host) VALUES ('pg-schema-test', '127.0.0.1')
`)
const { rows } = await dbQuery<{ id: number }>(`SELECT id FROM servers WHERE name = 'pg-schema-test' LIMIT 1`)
const id = rows[0]?.id
assert.ok(id)
await ensurePartitionFor(pool, "flow_buckets", "day", new Date())
const bucketAt = new Date().toISOString()
await dbQuery(`
INSERT INTO flow_buckets (
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface
) VALUES ($1, $2, '10.0.0.1', '8.8.8.8', 6, 1, 443, 10, 1, 'wg-flow')
ON CONFLICT DO NOTHING
`, [id, bucketAt])
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = $1`, [id])
await dbQuery(`DELETE FROM servers WHERE id = $1`, [id])
}
{
await dbQuery(`
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
VALUES ('pg-test-1', 'pg-dedupe-key', '{}'::jsonb, now())
ON CONFLICT (dedupe_key) DO NOTHING
`)
let threw = false
try {
await dbQuery(`
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
VALUES ('pg-test-2', 'pg-dedupe-key', '{}'::jsonb, now())
`)
} catch {
threw = true
}
assert.equal(threw, true, "alert_outbox.dedupe_key UNIQUE")
await dbQuery(`DELETE FROM alert_outbox WHERE dedupe_key = 'pg-dedupe-key'`)
}
console.log("pg-schema.test.ts: ok")
+420 -290
View File
@@ -1,73 +1,83 @@
import { sql } from "drizzle-orm"
import {
bigint,
boolean,
date,
doublePrecision,
index,
integer,
real,
sqliteTable,
jsonb,
pgTable,
primaryKey,
text,
} from "drizzle-orm/sqlite-core"
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
function ts(name: string) {
return timestamp(name, { withTimezone: true, mode: "string" as const })
}
function idIdentity() {
return bigint("id", { mode: "number" }).generatedByDefaultAsIdentity()
}
function idSingleton() {
return bigint("id", { mode: "number" }).primaryKey()
}
function intPkRef() {
return bigint("server_id", { mode: "number" }).notNull()
}
// ── servers ────────────────────────────────────────────────────────────────────
export const servers = sqliteTable("servers", {
id: integer("id").primaryKey({ autoIncrement: true }),
export const servers = pgTable("servers", {
id: idIdentity().primaryKey(),
name: text("name").notNull().default(""),
host: text("host").notNull(),
port: integer("port").notNull().default(443),
username: text("username").notNull().default("admin"),
password: text("password").notNull().default(""),
useSsl: integer("use_ssl", { mode: "boolean" }).notNull().default(true),
verifySsl: integer("verify_ssl", { mode: "boolean" }).notNull().default(false),
// metadata set manually by user
useSsl: boolean("use_ssl").notNull().default(true),
verifySsl: boolean("verify_ssl").notNull().default(false),
type: text("type", { enum: ["jump-host", "exit-node", "home-router"] })
.notNull().default("home-router"),
site: text("site").notNull().default(""),
country: text("country").notNull().default(""),
asn: text("asn").notNull().default(""),
comment: text("comment").notNull().default(""),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
/** Подсеть LAN (home-router), текст из формы */
enabled: boolean("enabled").notNull().default(true),
lanSubnet: text("lan_subnet").notNull().default(""),
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
wanUplinks: text("wan_uplinks").notNull().default("[]"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
wanUplinks: jsonb("wan_uplinks").notNull().default(sql`'[]'::jsonb`),
mgmtTunnelIp: text("mgmt_tunnel_ip").notNull().default(""),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
// ── server_snapshots ───────────────────────────────────────────────────────────
export const serverSnapshots = sqliteTable("server_snapshots", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
polledAt: text("polled_at").notNull(),
export const serverSnapshots = pgTable("server_snapshots", {
id: idIdentity(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
polledAt: ts("polled_at").notNull(),
status: text("status", { enum: ["online", "offline"] }).notNull(),
latencyMs: real("latency_ms"),
// data from RouterOS
latencyMs: doublePrecision("latency_ms"),
rosVersion: text("ros_version"),
boardName: text("board_name"),
uptime: text("uptime"),
cpuLoad: integer("cpu_load"),
freeMemory: integer("free_memory"),
totalMemory: integer("total_memory"),
freeMemory: bigint("free_memory", { mode: "number" }),
totalMemory: bigint("total_memory", { mode: "number" }),
identityName: text("identity_name"),
rawInterfaces: jsonb("raw_interfaces"),
rawIpAddresses: jsonb("raw_ip_addresses"),
}, (t) => [
primaryKey({ columns: [t.id, t.polledAt] }),
index("idx_server_snapshots_server_time").on(t.serverId, t.polledAt),
])
// raw JSON payloads for future use
rawInterfaces: text("raw_interfaces"),
rawIpAddresses: text("raw_ip_addresses"),
})
// ── filter_rules ───────────────────────────────────────────────────────────────
export const filterRules = sqliteTable("filter_rules", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
export const filterRules = pgTable("filter_rules", {
id: idIdentity().primaryKey(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
community: text("community").notNull(),
communityName: text("community_name"),
@@ -75,17 +85,15 @@ export const filterRules = sqliteTable("filter_rules", {
gateway: text("gateway").notNull().default(""),
gatewayTunnelId: text("gateway_tunnel_id").notNull().default(""),
description: text("description").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
}, (t) => [
index("idx_filter_rules_server_sort").on(t.serverId, t.sortOrder),
])
// ── recursive_routes ───────────────────────────────────────────────────────────
export const recursiveRoutes = sqliteTable("recursive_routes", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
export const recursiveRoutes = pgTable("recursive_routes", {
id: idIdentity().primaryKey(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
dstAddress: text("dst_address").notNull(),
gateway: text("gateway").notNull(),
@@ -96,236 +104,317 @@ export const recursiveRoutes = sqliteTable("recursive_routes", {
checkGateway: text("check_gateway").notNull().default(""),
country: text("country").notNull().default(""),
comment: text("comment").notNull().default(""),
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
disabled: boolean("disabled").notNull().default(false),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
}, (t) => [
index("idx_recursive_routes_server_sort").on(t.serverId, t.sortOrder),
])
// ── traffic collection settings ────────────────────────────────────────────────
export const trafficSettings = sqliteTable("traffic_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
export const trafficSettings = pgTable("traffic_settings", {
id: idSingleton(),
enabled: boolean("enabled").notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(30),
retentionDays: integer("retention_days").notNull().default(14),
lastCollectedAt: text("last_collected_at"),
lastCollectedAt: ts("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
/** Настройки фоновой проверки доступности RouterOS REST API по серверам каталога. */
export const serversApiPingSettings = sqliteTable("servers_api_ping_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
export const serversApiPingSettings = pgTable("servers_api_ping_settings", {
id: idSingleton(),
enabled: boolean("enabled").notNull().default(false),
intervalSec: integer("interval_sec").notNull().default(120),
lastCollectedAt: text("last_collected_at"),
lastCollectedAt: ts("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
/** Сырые пробы REST `/system/identity` по серверам — для UI сбора и для `buildSignalSnapshot` (алерты). */
export const serversRestPingSamples = sqliteTable("servers_rest_ping_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
sampledAt: text("sampled_at").notNull(),
ok: integer("ok", { mode: "boolean" }).notNull(),
export const serversRestPingSamples = pgTable("servers_rest_ping_samples", {
id: idIdentity(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
sampledAt: ts("sampled_at").notNull(),
ok: boolean("ok").notNull(),
latencyMs: integer("latency_ms"),
error: text("error"),
}, (t) => [
primaryKey({ columns: [t.id, t.sampledAt] }),
index("idx_servers_rest_ping_samples_server_id").on(t.serverId, t.sampledAt),
])
export const trafficFlowSettings = pgTable("traffic_flow_settings", {
id: idSingleton(),
enabled: boolean("enabled").notNull().default(false),
collectorIp: text("collector_ip").notNull().default("10.255.254.1"),
flowListenPort: integer("flow_listen_port").notNull().default(4739),
wgListenPort: integer("wg_listen_port").notNull().default(51821),
prefix: text("prefix").notNull().default("10.255.254.0/24"),
publicEndpoint: text("public_endpoint").notNull().default(""),
hostPublicKey: text("host_public_key").notNull().default(""),
hostPrivateKey: text("host_private_key").notNull().default(""),
hubServerId: bigint("hub_server_id", { mode: "number" }),
retentionHours: integer("retention_hours").notNull().default(24),
topN: integer("top_n").notNull().default(200),
mapServiceMinSharePct: doublePrecision("map_service_min_share_pct").notNull().default(5),
lastDatagramAt: ts("last_datagram_at"),
lastExporterIp: text("last_exporter_ip"),
lastError: text("last_error"),
packetsReceived: bigint("packets_received", { mode: "number" }).notNull().default(0),
peersJson: jsonb("peers_json").notNull().default(sql`'[]'::jsonb`),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
/** Сырые снимки GRE для алертов — пишет джоба `gre_bgp`. */
export const alertGreTunnelSamples = sqliteTable("alert_gre_tunnel_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
sampledAt: text("sampled_at").notNull(),
targetLabel: text("target_label").notNull(),
status: text("status").notNull(),
export const flowMinuteStats = pgTable("flow_minute_stats", {
serverId: bigint("server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
bucketAt: ts("bucket_at").notNull(),
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
packets: bigint("packets", { mode: "number" }).notNull().default(0),
uniqueSrc: integer("unique_src").notNull().default(0),
uniqueDst: integer("unique_dst").notNull().default(0),
conversations: integer("conversations").notNull().default(0),
}, (t) => [
primaryKey({ columns: [t.serverId, t.bucketAt] }),
])
export const flowMinuteDims = pgTable("flow_minute_dims", {
serverId: bigint("server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
bucketAt: ts("bucket_at").notNull(),
dim: text("dim").notNull(),
key: text("key").notNull(),
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
packets: bigint("packets", { mode: "number" }).notNull().default(0),
}, (t) => [
primaryKey({ columns: [t.serverId, t.bucketAt, t.dim, t.key] }),
index("idx_flow_minute_dims_time").on(t.bucketAt, t.dim),
])
export const flowDailyDims = pgTable("flow_daily_dims", {
serverId: bigint("server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
day: date("day", { mode: "string" }).notNull(),
dim: text("dim").notNull(),
key: text("key").notNull(),
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
packets: bigint("packets", { mode: "number" }).notNull().default(0),
}, (t) => [
primaryKey({ columns: [t.serverId, t.day, t.dim, t.key] }),
index("idx_flow_daily_dims_day").on(t.day, t.dim),
])
export const flowBuckets = pgTable("flow_buckets", {
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
bucketAt: ts("bucket_at").notNull(),
src: text("src").notNull(),
dst: text("dst").notNull(),
proto: integer("proto").notNull().default(0),
srcPort: integer("src_port").notNull().default(0),
dstPort: integer("dst_port").notNull().default(0),
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
packets: bigint("packets", { mode: "number" }).notNull().default(0),
inIface: text("in_iface").notNull().default(""),
outIface: text("out_iface").notNull().default(""),
nextHop: text("next_hop").notNull().default(""),
flowStartMs: bigint("flow_start_ms", { mode: "number" }).notNull().default(0),
flowEndMs: bigint("flow_end_ms", { mode: "number" }).notNull().default(0),
}, (t) => [
primaryKey({
name: "flow_buckets_pkey",
columns: [t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort, t.inIface],
}),
index("idx_flow_buckets_server_time").on(t.serverId, t.bucketAt),
])
export const flowIpMeta = pgTable("flow_ip_meta", {
prefix: text("prefix").primaryKey(),
asn: integer("asn").notNull().default(0),
country: text("country").notNull().default(""),
lat: doublePrecision("lat"),
lng: doublePrecision("lng"),
holder: text("holder").notNull().default(""),
ok: integer("ok").notNull().default(1),
fetchedAt: ts("fetched_at").notNull(),
})
/** Сырые снимки BGP-сессий для алертов — пишет джоба `gre_bgp`. */
export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
sampledAt: text("sampled_at").notNull(),
peerKey: text("peer_key").notNull(),
state: text("state").notNull(),
export const flowAsnMeta = pgTable("flow_asn_meta", {
asn: integer("asn").primaryKey(),
holder: text("holder").notNull().default(""),
fetchedAt: ts("fetched_at").notNull(),
})
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
export const trafficSamples = sqliteTable("traffic_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
export const trafficSamples = pgTable("traffic_samples", {
id: idIdentity(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
sampledAt: text("sampled_at").notNull(),
rxBytes: integer("rx_bytes").notNull().default(0),
txBytes: integer("tx_bytes").notNull().default(0),
rxBps: integer("rx_bps").notNull().default(0),
txBps: integer("tx_bps").notNull().default(0),
running: integer("running", { mode: "boolean" }).notNull().default(false),
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
})
peerPublicKey: text("peer_public_key").notNull().default(""),
sampledAt: ts("sampled_at").notNull(),
rxBytes: bigint("rx_bytes", { mode: "number" }).notNull().default(0),
txBytes: bigint("tx_bytes", { mode: "number" }).notNull().default(0),
rxBps: bigint("rx_bps", { mode: "number" }).notNull().default(0),
txBps: bigint("tx_bps", { mode: "number" }).notNull().default(0),
running: boolean("running").notNull().default(false),
disabled: boolean("disabled").notNull().default(false),
}, (t) => [
primaryKey({ columns: [t.id, t.sampledAt] }),
index("idx_traffic_samples_server_time").on(t.serverId, t.sampledAt),
index("idx_traffic_samples_server_iface_time").on(t.serverId, t.interfaceName, t.sampledAt),
])
// ── uptime monitor settings ────────────────────────────────────────────────────
export const uptimeSettings = sqliteTable("uptime_settings", {
id: integer("id").primaryKey(),
/** Устаревший агрегат: синхронизируется как resources ping speed (для совместимости). */
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
resourcesEnabled: integer("resources_enabled", { mode: "boolean" }).notNull().default(true),
pingEnabled: integer("ping_enabled", { mode: "boolean" }).notNull().default(true),
speedEnabled: integer("speed_enabled", { mode: "boolean" }).notNull().default(true),
export const uptimeSettings = pgTable("uptime_settings", {
id: idSingleton(),
enabled: boolean("enabled").notNull().default(true),
resourcesEnabled: boolean("resources_enabled").notNull().default(true),
pingEnabled: boolean("ping_enabled").notNull().default(true),
speedEnabled: boolean("speed_enabled").notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(15),
probeIntervalSec: integer("probe_interval_sec").notNull().default(15),
speedIntervalSec: integer("speed_interval_sec").notNull().default(60),
retentionDays: integer("retention_days").notNull().default(14),
lastCollectedAt: text("last_collected_at"),
lastCollectedAt: ts("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const uptimeProbes = sqliteTable("uptime_probes", {
export const uptimeProbes = pgTable("uptime_probes", {
id: text("id").primaryKey(),
srcServerId: integer("src_server_id")
.notNull()
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
srcInterface: text("src_interface").notNull().default(""),
name: text("name").notNull(),
target: text("target").notNull(),
probeFilter: text("probe_filter").notNull().default("—"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
/** 0 — брать глобальный probe_interval_sec из uptime_settings */
enabled: boolean("enabled").notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(0),
/** Выводить пробу в блоке «Активные пробы» на дашборде */
showOnDashboard: integer("show_on_dashboard", { mode: "boolean" }).notNull().default(false),
showOnDashboard: boolean("show_on_dashboard").notNull().default(false),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
}, (t) => [
index("idx_uptime_probes_server_sort").on(t.srcServerId, t.sortOrder),
])
export const uptimeProbeSamples = sqliteTable("uptime_probe_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
probeId: text("probe_id")
.notNull()
.references(() => uptimeProbes.id, { onDelete: "cascade" }),
sampledAt: text("sampled_at").notNull(),
export const uptimeProbeSamples = pgTable("uptime_probe_samples", {
id: idIdentity(),
probeId: text("probe_id").notNull().references(() => uptimeProbes.id, { onDelete: "cascade" }),
sampledAt: ts("sampled_at").notNull(),
rttMs: integer("rtt_ms"),
lossPct: integer("loss_pct").notNull().default(0),
status: text("status", { enum: ["up", "warn", "down"] }).notNull().default("down"),
})
}, (t) => [
primaryKey({ columns: [t.id, t.sampledAt] }),
index("idx_uptime_probe_samples_probe_time").on(t.probeId, t.sampledAt),
])
export const uptimeResourceSamples = sqliteTable("uptime_resource_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
sampledAt: text("sampled_at").notNull(),
export const uptimeResourceSamples = pgTable("uptime_resource_samples", {
id: idIdentity(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
sampledAt: ts("sampled_at").notNull(),
status: text("status", { enum: ["online", "offline"] }).notNull().default("offline"),
cpuLoad: integer("cpu_load").notNull().default(0),
freeMemory: integer("free_memory").notNull().default(0),
totalMemory: integer("total_memory").notNull().default(0),
freeHddSpace: integer("free_hdd_space").notNull().default(0),
totalHddSpace: integer("total_hdd_space").notNull().default(0),
uptimeSeconds: integer("uptime_seconds").notNull().default(0),
freeMemory: bigint("free_memory", { mode: "number" }).notNull().default(0),
totalMemory: bigint("total_memory", { mode: "number" }).notNull().default(0),
freeHddSpace: bigint("free_hdd_space", { mode: "number" }).notNull().default(0),
totalHddSpace: bigint("total_hdd_space", { mode: "number" }).notNull().default(0),
uptimeSeconds: bigint("uptime_seconds", { mode: "number" }).notNull().default(0),
boardName: text("board_name").notNull().default(""),
rosVersion: text("ros_version").notNull().default(""),
})
}, (t) => [
primaryKey({ columns: [t.id, t.sampledAt] }),
index("idx_uptime_resource_samples_server_time").on(t.serverId, t.sampledAt),
])
export const uptimeSpeedProbes = sqliteTable("uptime_speed_probes", {
export const uptimeSpeedProbes = pgTable("uptime_speed_probes", {
id: text("id").primaryKey(),
srcServerId: integer("src_server_id")
.notNull()
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
dstServerId: integer("dst_server_id")
.notNull()
dstServerId: bigint("dst_server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
srcInterface: text("src_interface").notNull().default(""),
dstInterface: text("dst_interface").notNull().default(""),
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
durationSec: integer("duration_sec").notNull().default(10),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lastRunAt: text("last_run_at"),
lastTxAvgMbps: real("last_tx_avg_mbps"),
lastRxAvgMbps: real("last_rx_avg_mbps"),
enabled: boolean("enabled").notNull().default(true),
lastRunAt: ts("last_run_at"),
lastTxAvgMbps: doublePrecision("last_tx_avg_mbps"),
lastRxAvgMbps: doublePrecision("last_rx_avg_mbps"),
lastStatus: text("last_status", { enum: ["done", "error"] }),
lastError: text("last_error"),
lastPingRttMs: integer("last_ping_rtt_ms"),
lastPingLossPct: integer("last_ping_loss_pct"),
lastPingAt: text("last_ping_at"),
lastPingAt: ts("last_ping_at"),
lastPingError: text("last_ping_error"),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
}, (t) => [
index("idx_uptime_speed_probes_src_sort").on(t.srcServerId, t.sortOrder),
])
// ── EvoBGP integration (URL + API key на сервере) ─────────────────────────────
export const evobgpSettings = sqliteTable("evobgp_settings", {
id: integer("id").primaryKey(),
export const evobgpSettings = pgTable("evobgp_settings", {
id: idSingleton(),
baseUrl: text("base_url").notNull().default(""),
apiKey: text("api_key").notNull().default(""),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
enabled: boolean("enabled").notNull().default(false),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
/** Единственная строка id=1: токен и чат Telegram для оповещений. */
export const alertTelegramSettings = sqliteTable("alert_telegram_settings", {
id: integer("id").primaryKey(),
export const alertTelegramSettings = pgTable("alert_telegram_settings", {
id: idSingleton(),
botToken: text("bot_token").notNull().default(""),
chatId: text("chat_id").notNull().default(""),
/** Тема супергруппы (forum): `message_thread_id` в Bot API; NULL — общий чат */
messageThreadId: integer("message_thread_id"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const acmeSettings = sqliteTable("acme_settings", {
id: integer("id").primaryKey(),
export const acmeSettings = pgTable("acme_settings", {
id: idSingleton(),
directoryUrl: text("directory_url").notNull().default("https://acme-v02.api.letsencrypt.org/directory"),
cloudflareApiToken: text("cloudflare_api_token").notNull().default(""),
defaultZoneId: text("default_zone_id").notNull().default(""),
accountPrivateKey: text("account_private_key").notNull().default(""),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
export const certificateIssueJobs = pgTable("certificate_issue_jobs", {
id: text("id").primaryKey(),
status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"),
step: text("step").notNull().default("queued"),
source: text("source", { enum: ["manual", "scheduler"] }).notNull().default("manual"),
serverId: text("server_id").notNull(),
serverId: bigint("server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
certName: text("cert_name").notNull(),
domainNames: text("domain_names").notNull(),
domainNames: jsonb("domain_names").notNull(),
keyType: text("key_type").notNull().default("rsa2048"),
trustStore: text("trust_store").notNull().default("www,api"),
requestedAt: text("requested_at").notNull().default(sql`(datetime('now'))`),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
requestedAt: ts("requested_at").notNull().defaultNow(),
startedAt: ts("started_at"),
finishedAt: ts("finished_at"),
error: text("error"),
})
export const certificateRenewSettings = sqliteTable("certificate_renew_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
export const certificateRenewSettings = pgTable("certificate_renew_settings", {
id: idSingleton(),
enabled: boolean("enabled").notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(21600),
renewBeforeDays: integer("renew_before_days").notNull().default(30),
lastCollectedAt: text("last_collected_at"),
lastCollectedAt: ts("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
export const backupScheduleSettings = pgTable("backup_schedule_settings", {
id: idSingleton(),
enabled: boolean("enabled").notNull().default(true),
frequency: text("frequency", { enum: ["daily", "weekly", "monthly"] }).notNull().default("daily"),
hour: integer("hour").notNull().default(3),
minute: integer("minute").notNull().default(0),
@@ -333,167 +422,153 @@ export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
monthDay: integer("month_day").notNull().default(1),
keepCount: integer("keep_count").notNull().default(7),
format: text("format", { enum: ["rsc", "backup"] }).notNull().default("rsc"),
serverIdsJson: text("server_ids_json").notNull().default("[]"),
lastRunAt: text("last_run_at"),
serverIdsJson: jsonb("server_ids_json").notNull().default(sql`'[]'::jsonb`),
lastRunAt: ts("last_run_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const backupEntries = sqliteTable("backup_entries", {
export const backupEntries = pgTable("backup_entries", {
id: text("id").primaryKey(),
serverId: text("server_id").notNull(),
serverId: bigint("server_id", { mode: "number" })
.references(() => servers.id, { onDelete: "set null" }),
serverName: text("server_name").notNull(),
filename: text("filename").notNull(),
sizeBytes: integer("size_bytes").notNull(),
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
notes: text("notes"),
createdAt: text("created_at").notNull(),
})
createdAt: ts("created_at").notNull(),
}, (t) => [
uniqueIndex("idx_backup_entries_filename").on(t.filename),
index("idx_backup_entries_server_created").on(t.serverId, t.createdAt),
])
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
export const alertGroups = sqliteTable("alert_groups", {
export const alertGroups = pgTable("alert_groups", {
id: text("id").primaryKey(),
name: text("name").notNull(),
combineMode: text("combine_mode", { enum: ["any", "all"] }).notNull().default("any"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
/** Переопределение cooldown для агрегата группы; NULL — по умолчанию 5м */
enabled: boolean("enabled").notNull().default(true),
cooldownOverride: text("cooldown_override", {
enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"],
}),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
/** Правила оповещений (UI /alerts). */
export const alertRules = sqliteTable("alert_rules", {
export const alertRules = pgTable("alert_rules", {
id: text("id").primaryKey(),
name: text("name").notNull(),
type: text("type", {
enum: ["gre-tunnel", "bgp-peer", "bgp-prefix", "gre-client", "server", "rtt", "loss", "traffic"],
}).notNull(),
/** Сводная подпись (первый target или join); для совместимости и поиска */
target: text("target").notNull(),
condition: text("condition").notNull(),
severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
enabled: boolean("enabled").notNull().default(true),
cooldown: text("cooldown", {
enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"],
}).notNull().default("5м"),
chatId: text("rule_chat_id").notNull().default(""),
/** NULL / 0 — выкл. Секунды: уведомление только если условие держится столько времени без «отмены» (восстановление сигнала). */
confirmStabilitySec: integer("confirm_stability_sec"),
/** Политика recovery-уведомления для правила. */
recoveryMode: text("recovery_mode", {
enum: ["always", "never", "conditional"],
}).notNull().default("always"),
/** Доп. задержка подтверждения для recovery при `conditional`; NULL — без доп. ожидания. */
recoveryStabilitySec: integer("recovery_stability_sec"),
/** NULL — правило вне группы (отдельные отправки по cooldown правила) */
groupId: text("group_id"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
groupId: text("group_id").references(() => alertGroups.id, { onDelete: "set null" }),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
/** Несколько объектов на правило; срабатывание по OR (хотя бы один). */
export const alertRuleTargets = sqliteTable("alert_rule_targets", {
export const alertRuleTargets = pgTable("alert_rule_targets", {
id: text("id").primaryKey(),
ruleId: text("rule_id").notNull(),
ruleId: text("rule_id").notNull().references(() => alertRules.id, { onDelete: "cascade" }),
target: text("target").notNull(),
sortIndex: integer("sort_index").notNull().default(0),
})
}, (t) => [
index("idx_alert_rule_targets_rule").on(t.ruleId),
])
/** Несколько условий на правило; срабатывание по OR (любое из выбранных). */
export const alertRuleConditions = sqliteTable("alert_rule_conditions", {
export const alertRuleConditions = pgTable("alert_rule_conditions", {
id: text("id").primaryKey(),
ruleId: text("rule_id").notNull(),
ruleId: text("rule_id").notNull().references(() => alertRules.id, { onDelete: "cascade" }),
conditionLine: text("condition_line").notNull(),
sortIndex: integer("sort_index").notNull().default(0),
})
}, (t) => [
index("idx_alert_rule_conditions_rule").on(t.ruleId),
])
/** Состояние движка: cooldown / дедуп по ключу rule:id или group:id */
export const alertEngineState = sqliteTable("alert_engine_state", {
export const alertEngineState = pgTable("alert_engine_state", {
scopeKey: text("scope_key").primaryKey(),
lastFiredAt: text("last_fired_at").notNull().default(""),
lastPayloadHash: text("last_payload_hash"),
})
/** Снимок прошлого тика для live-сигналов (GRE/BGP): переходы «offline» / «восстановился». */
export const alertEnginePrevLive = sqliteTable("alert_engine_prev_live", {
export const alertEnginePrevLive = pgTable("alert_engine_prev_live", {
kind: text("kind", { enum: ["gre", "bgp"] }).primaryKey(),
payloadJson: text("payload_json").notNull().default("{}"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
payloadJson: jsonb("payload_json").notNull().default(sql`'{}'::jsonb`),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
/** Ожидание стабильности срабатывания по правилу (антидребезг). */
export const alertEngineConfirmPending = sqliteTable("alert_engine_confirm_pending", {
export const alertEngineConfirmPending = pgTable("alert_engine_confirm_pending", {
ruleId: text("rule_id").primaryKey(),
payloadHash: text("payload_hash").notNull(),
sinceAt: text("since_at").notNull(),
sinceAt: ts("since_at").notNull(),
})
/** Задел: несколько получателей Telegram (пока UI не подключён) */
export const alertDestinations = sqliteTable("alert_destinations", {
id: text("id").primaryKey(),
kind: text("kind", { enum: ["telegram"] }).notNull().default("telegram"),
label: text("label").notNull().default(""),
telegramChatId: text("telegram_chat_id").notNull().default(""),
messageThreadId: integer("message_thread_id"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
})
/** Журнал отправок / срабатываний (персист + при необходимости пополняется воркером). */
export const alertHistory = sqliteTable("alert_history", {
export const alertHistory = pgTable("alert_history", {
id: text("id").primaryKey(),
ruleId: text("rule_id"),
groupId: text("group_id"),
ruleName: text("rule_name").notNull(),
severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(),
message: text("message").notNull(),
sentOk: integer("sent_ok", { mode: "boolean" }).notNull().default(true),
firedAt: text("fired_at").notNull(),
})
sentOk: boolean("sent_ok").notNull().default(true),
firedAt: ts("fired_at").notNull(),
}, (t) => [
index("idx_alert_history_fired_at").on(t.firedAt),
index("idx_alert_history_rule_id").on(t.ruleId),
])
/** Outbox доставки уведомлений: ретраи и идемпотентность отправки каналов. */
export const alertOutbox = sqliteTable("alert_outbox", {
export const alertOutbox = pgTable("alert_outbox", {
id: text("id").primaryKey(),
dedupeKey: text("dedupe_key").notNull(),
channel: text("channel", { enum: ["telegram"] }).notNull().default("telegram"),
status: text("status", { enum: ["pending", "sent", "failed"] }).notNull().default("pending"),
retryCount: integer("retry_count").notNull().default(0),
maxRetries: integer("max_retries").notNull().default(3),
nextAttemptAt:text("next_attempt_at").notNull(),
payloadJson: text("payload_json").notNull(),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
sentAt: text("sent_at"),
nextAttemptAt: ts("next_attempt_at").notNull(),
payloadJson: jsonb("payload_json").notNull(),
createdAt: ts("created_at").notNull().defaultNow(),
sentAt: ts("sent_at"),
lastError: text("last_error"),
}, (t) => [
uniqueIndex("idx_alert_outbox_dedupe").on(t.dedupeKey),
index("idx_alert_outbox_status_next_attempt").on(t.status, t.nextAttemptAt),
])
export const alertEngineCursor = pgTable("alert_engine_cursor", {
id: idSingleton(),
lastSourceFinishedAt: ts("last_source_finished_at"),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
/** Cursor движка: watermark последней обработанной точки snapshot-источников. */
export const alertEngineCursor = sqliteTable("alert_engine_cursor", {
id: integer("id").primaryKey(),
lastSourceFinishedAt: text("last_source_finished_at"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
/** Журнал прогонов планировщика (аудит). */
export const schedulerRuns = sqliteTable("scheduler_runs", {
export const schedulerRuns = pgTable("scheduler_runs", {
id: text("id").primaryKey(),
jobKey: text("job_key").notNull(),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at").notNull(),
startedAt: ts("started_at").notNull(),
finishedAt: ts("finished_at").notNull(),
status: text("status", { enum: ["ok", "error"] }).notNull(),
error: text("error"),
durationMs: integer("duration_ms").notNull().default(0),
/** JSON: см. `SchedulerRunSnapshot` в types/scheduler-run-snapshot.ts */
resultJson: text("result_json"),
})
resultJson: jsonb("result_json"),
}, (t) => [
index("idx_scheduler_runs_job_time").on(t.jobKey, t.startedAt),
])
/** Централизованный append-only журнал событий системы. */
export const events = sqliteTable("events", {
export const events = pgTable("events", {
id: text("id").primaryKey(),
createdAt: text("created_at").notNull(),
createdAt: ts("created_at").notNull(),
level: text("level", { enum: ["critical", "warning", "info"] }).notNull(),
eventType: text("event_type").notNull(),
sourceModule: text("source_module").notNull(),
@@ -501,14 +576,21 @@ export const events = sqliteTable("events", {
message: text("message").notNull(),
entityType: text("entity_type"),
entityId: text("entity_id"),
payloadJson: text("payload_json"),
})
payloadJson: jsonb("payload_json"),
}, (t) => [
index("idx_events_created_at").on(t.createdAt),
index("idx_events_level_created_at").on(t.level, t.createdAt),
index("idx_events_source_created_at").on(t.sourceModule, t.createdAt),
index("idx_events_event_type_created_at").on(t.eventType, t.createdAt),
])
export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
export const uptimeSpeedTestRuns = pgTable("uptime_speed_test_runs", {
id: text("id").primaryKey(),
probeId: text("probe_id"),
srcServerId: integer("src_server_id").notNull().references(() => servers.id, { onDelete: "cascade" }),
dstServerId: integer("dst_server_id").notNull().references(() => servers.id, { onDelete: "cascade" }),
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
dstServerId: bigint("dst_server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
srcInterface: text("src_interface").notNull().default(""),
dstInterface: text("dst_interface").notNull().default(""),
srcAddress: text("src_address"),
@@ -518,35 +600,78 @@ export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
durationSec: integer("duration_sec").notNull().default(10),
txAvgMbps: real("tx_avg_mbps"),
rxAvgMbps: real("rx_avg_mbps"),
txAvgMbps: doublePrecision("tx_avg_mbps"),
rxAvgMbps: doublePrecision("rx_avg_mbps"),
pingRttMs: integer("ping_rtt_ms"),
pingLossPct: integer("ping_loss_pct"),
pingError: text("ping_error"),
status: text("status", { enum: ["done", "error"] }).notNull().default("done"),
error: text("error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
})
createdAt: ts("created_at").notNull().defaultNow(),
}, (t) => [
index("idx_uptime_speed_test_runs_created_at").on(t.createdAt),
])
export const internetPathSettings = sqliteTable("internet_path_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
export const internetPathSettings = pgTable("internet_path_settings", {
id: idSingleton(),
enabled: boolean("enabled").notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(300),
retentionDays: integer("retention_days").notNull().default(14),
lastCollectedAt: text("last_collected_at"),
lastCollectedAt: ts("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
id: integer("id").primaryKey({ autoIncrement: true }),
sampledAt: text("sampled_at").notNull(),
payloadJson: text("payload_json").notNull(),
export const appUsers = pgTable("app_users", {
id: text("id").primaryKey(),
name: text("name").notNull().default(""),
login: text("login").notNull().unique(),
email: text("email").notNull().default(""),
role: text("role", { enum: ["admin", "operator", "viewer"] }).notNull().default("viewer"),
active: boolean("active").notNull().default(true),
avatar: text("avatar").notNull().default(""),
lastSeen: ts("last_seen"),
sectionsJson: jsonb("sections_json").notNull().default(sql`'[]'::jsonb`),
serversJson: jsonb("servers_json").notNull().default(sql`'[]'::jsonb`),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
// ── inferred types ─────────────────────────────────────────────────────────────
export const userInterfaceBindings = pgTable("user_interface_bindings", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => appUsers.id, { onDelete: "cascade" }),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
.notNull().default("other"),
peerPublicKey: text("peer_public_key").notNull().default(""),
peerName: text("peer_name").notNull().default(""),
comment: text("comment").notNull().default(""),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
}, (t) => [
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
index("idx_user_iface_bind_user").on(t.userId),
])
export const internetPathSnapshots = pgTable("internet_path_snapshots", {
id: idIdentity(),
sampledAt: ts("sampled_at").notNull(),
payloadJson: jsonb("payload_json").notNull(),
}, (t) => [
primaryKey({ columns: [t.id, t.sampledAt] }),
index("idx_internet_path_snapshots_sampled").on(t.sampledAt),
])
export const dataMigration = pgTable("data_migration", {
id: integer("id").primaryKey(),
sqliteImportedAt: ts("sqlite_imported_at"),
sqlitePath: text("sqlite_path"),
sqliteSha256: text("sqlite_sha256"),
reportJson: jsonb("report_json"),
})
export type Server = typeof servers.$inferSelect
export type ServerInsert = typeof servers.$inferInsert
@@ -555,6 +680,10 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
export type FilterRuleRow = typeof filterRules.$inferSelect
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
export type FlowBucketRow = typeof flowBuckets.$inferSelect
export type FlowIpMetaRow = typeof flowIpMeta.$inferSelect
export type FlowAsnMetaRow = typeof flowAsnMeta.$inferSelect
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
@@ -578,7 +707,8 @@ export type AlertGroupRow = typeof alertGroups.$inferSelect
export type AlertRuleRow = typeof alertRules.$inferSelect
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
export type AlertEngineStateRow = typeof alertEngineState.$inferSelect
export type AlertDestinationRow = typeof alertDestinations.$inferSelect
export type AlertHistoryRow = typeof alertHistory.$inferSelect
export type AlertOutboxRow = typeof alertOutbox.$inferSelect
export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect
export type AppUserRow = typeof appUsers.$inferSelect
export type UserInterfaceBindingRow = typeof userInterfaceBindings.$inferSelect
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import { bindSql } from "./sql-bind.js"
{
const q = bindSql("SELECT 1")
assert.deepEqual(q, { text: "SELECT 1", values: [] })
}
{
const q = bindSql("SELECT * FROM t WHERE a = ? AND b = ?", [1, "x"])
assert.equal(q.text, "SELECT * FROM t WHERE a = $1 AND b = $2")
assert.deepEqual(q.values, [1, "x"])
}
{
const q = bindSql("INSERT INTO t (a, b) VALUES (@a, @b)", { a: 3, b: "y" })
assert.equal(q.text, "INSERT INTO t (a, b) VALUES ($1, $2)")
assert.deepEqual(q.values, [3, "y"])
}
console.log("sql-bind.test.ts: ok")
+22
View File
@@ -0,0 +1,22 @@
/** Convert SQLite-style @name / ? placeholders to node-pg $n. */
export function bindSql(
sql: string,
params?: unknown[] | Record<string, unknown>,
): { text: string; values: unknown[] } {
if (params == null) return { text: sql, values: [] }
if (Array.isArray(params)) {
let i = 0
const text = sql.replace(/\?/g, () => {
i += 1
return `$${i}`
})
return { text, values: params }
}
const values: unknown[] = []
const text = sql.replace(/@([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name: string) => {
values.push(params[name])
return `$${values.length}`
})
return { text, values }
}
+479
View File
@@ -0,0 +1,479 @@
import { createHash } from "node:crypto"
import { existsSync, readFileSync } from "node:fs"
import Database from "better-sqlite3"
import type { Pool } from "pg"
import { env } from "../config.js"
import { ensurePartitionFor, specForParent } from "./partitions.js"
export interface ImportReport {
sqlitePath: string
sqliteSha256: string
tables: Record<string, { sqlite: number; copied: number; skipped: number }>
rejects: string[]
durationMs: number
}
const SNAPSHOT_RETENTION_DAYS = 14
type ColKind = "ts" | "date" | "bool" | "json" | "json-null" | "bigint-id" | "int" | "text" | "num"
interface TableCopy {
table: string
columns: Array<[string, ColKind]>
timeCol?: string
retentionDays?: number
identity?: boolean
upsert?: boolean
}
const TABLES: TableCopy[] = [
{ table: "servers", identity: true, columns: [
["id", "int"], ["name", "text"], ["host", "text"], ["port", "int"], ["username", "text"],
["password", "text"], ["use_ssl", "bool"], ["verify_ssl", "bool"], ["type", "text"],
["site", "text"], ["country", "text"], ["asn", "text"], ["comment", "text"], ["enabled", "bool"],
["lan_subnet", "text"], ["wan_uplinks", "json"], ["mgmt_tunnel_ip", "text"],
["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "traffic_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "servers_api_ping_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"],
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "traffic_flow_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["collector_ip", "text"], ["flow_listen_port", "int"],
["wg_listen_port", "int"], ["prefix", "text"], ["public_endpoint", "text"],
["host_public_key", "text"], ["host_private_key", "text"], ["hub_server_id", "int"],
["retention_hours", "int"], ["top_n", "int"], ["map_service_min_share_pct", "num"],
["last_datagram_at", "ts"], ["last_exporter_ip", "text"], ["last_error", "text"],
["packets_received", "int"], ["peers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "uptime_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["resources_enabled", "bool"], ["ping_enabled", "bool"],
["speed_enabled", "bool"], ["interval_sec", "int"], ["probe_interval_sec", "int"],
["speed_interval_sec", "int"], ["retention_days", "int"], ["last_collected_at", "ts"],
["last_duration_ms", "int"], ["last_error", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "evobgp_settings", upsert: true, columns: [
["id", "int"], ["base_url", "text"], ["api_key", "text"], ["enabled", "bool"], ["updated_at", "ts"],
]},
{ table: "alert_telegram_settings", upsert: true, columns: [
["id", "int"], ["bot_token", "text"], ["chat_id", "text"], ["message_thread_id", "int"], ["updated_at", "ts"],
]},
{ table: "acme_settings", upsert: true, columns: [
["id", "int"], ["directory_url", "text"], ["cloudflare_api_token", "text"],
["default_zone_id", "text"], ["account_private_key", "text"], ["updated_at", "ts"],
]},
{ table: "certificate_renew_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["renew_before_days", "int"],
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"], ["updated_at", "ts"],
]},
{ table: "backup_schedule_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["frequency", "text"], ["hour", "int"], ["minute", "int"],
["week_day", "int"], ["month_day", "int"], ["keep_count", "int"], ["format", "text"],
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
["last_error", "text"], ["updated_at", "ts"],
]},
{ table: "internet_path_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "alert_engine_cursor", upsert: true, columns: [
["id", "int"], ["last_source_finished_at", "ts"], ["updated_at", "ts"],
]},
{ table: "filter_rules", identity: true, columns: [
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["community", "text"],
["community_name", "text"], ["action", "text"], ["gateway", "text"],
["gateway_tunnel_id", "text"], ["description", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "recursive_routes", identity: true, columns: [
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["dst_address", "text"],
["gateway", "text"], ["distance", "int"], ["scope", "int"], ["target_scope", "int"],
["routing_table", "text"], ["check_gateway", "text"], ["country", "text"], ["comment", "text"],
["disabled", "bool"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "server_snapshots", identity: true, timeCol: "polled_at", retentionDays: SNAPSHOT_RETENTION_DAYS, columns: [
["id", "int"], ["server_id", "int"], ["polled_at", "ts"], ["status", "text"], ["latency_ms", "num"],
["ros_version", "text"], ["board_name", "text"], ["uptime", "text"], ["cpu_load", "int"],
["free_memory", "int"], ["total_memory", "int"], ["identity_name", "text"],
["raw_interfaces", "json-null"], ["raw_ip_addresses", "json-null"],
]},
{ table: "traffic_samples", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
["id", "int"], ["server_id", "int"], ["interface_name", "text"], ["peer_public_key", "text"],
["sampled_at", "ts"], ["rx_bytes", "int"], ["tx_bytes", "int"], ["rx_bps", "int"], ["tx_bps", "int"],
["running", "bool"], ["disabled", "bool"],
]},
{ table: "servers_rest_ping_samples", identity: true, timeCol: "sampled_at", retentionDays: 30, columns: [
["id", "int"], ["server_id", "int"], ["sampled_at", "ts"], ["ok", "bool"], ["latency_ms", "int"], ["error", "text"],
]},
{ table: "flow_buckets", timeCol: "bucket_at", retentionDays: 2, columns: [
["server_id", "int"], ["bucket_at", "ts"], ["src", "text"], ["dst", "text"], ["proto", "int"],
["src_port", "int"], ["dst_port", "int"], ["bytes", "int"], ["packets", "int"],
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "text"],
["flow_start_ms", "int"], ["flow_end_ms", "int"],
]},
{ table: "flow_minute_stats", timeCol: "bucket_at", retentionDays: 3, columns: [
["server_id", "int"], ["bucket_at", "ts"], ["bytes", "int"], ["packets", "int"],
["unique_src", "int"], ["unique_dst", "int"], ["conversations", "int"],
]},
{ table: "flow_minute_dims", timeCol: "bucket_at", retentionDays: 3, columns: [
["server_id", "int"], ["bucket_at", "ts"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
]},
{ table: "flow_daily_dims", timeCol: "day", retentionDays: 396, columns: [
["server_id", "int"], ["day", "date"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
]},
{ table: "flow_ip_meta", columns: [
["prefix", "text"], ["asn", "int"], ["country", "text"], ["lat", "num"], ["lng", "num"],
["holder", "text"], ["ok", "int"], ["fetched_at", "ts"],
]},
{ table: "flow_asn_meta", columns: [
["asn", "int"], ["holder", "text"], ["fetched_at", "ts"],
]},
{ table: "uptime_probes", columns: [
["id", "text"], ["src_server_id", "int"], ["src_interface", "text"], ["name", "text"],
["target", "text"], ["probe_filter", "text"], ["enabled", "bool"], ["interval_sec", "int"],
["show_on_dashboard", "bool"], ["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "uptime_probe_samples", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
["id", "int"], ["probe_id", "text"], ["sampled_at", "ts"], ["rtt_ms", "int"], ["loss_pct", "int"], ["status", "text"],
]},
{ table: "uptime_resource_samples", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
["id", "int"], ["server_id", "int"], ["sampled_at", "ts"], ["status", "text"], ["cpu_load", "int"],
["free_memory", "int"], ["total_memory", "int"], ["free_hdd_space", "int"], ["total_hdd_space", "int"],
["uptime_seconds", "int"], ["board_name", "text"], ["ros_version", "text"],
]},
{ table: "uptime_speed_probes", columns: [
["id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"], ["src_interface", "text"],
["dst_interface", "text"], ["protocol", "text"], ["direction", "text"], ["duration_sec", "int"],
["enabled", "bool"], ["last_run_at", "ts"], ["last_tx_avg_mbps", "num"], ["last_rx_avg_mbps", "num"],
["last_status", "text"], ["last_error", "text"], ["last_ping_rtt_ms", "int"],
["last_ping_loss_pct", "int"], ["last_ping_at", "ts"], ["last_ping_error", "text"],
["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "uptime_speed_test_runs", columns: [
["id", "text"], ["probe_id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"],
["src_interface", "text"], ["dst_interface", "text"], ["src_address", "text"], ["dst_address", "text"],
["src_interface_address", "text"], ["dst_interface_address", "text"], ["protocol", "text"],
["direction", "text"], ["duration_sec", "int"], ["tx_avg_mbps", "num"], ["rx_avg_mbps", "num"],
["ping_rtt_ms", "int"], ["ping_loss_pct", "int"], ["ping_error", "text"], ["status", "text"],
["error", "text"], ["created_at", "ts"],
]},
{ table: "app_users", columns: [
["id", "text"], ["name", "text"], ["login", "text"], ["email", "text"], ["role", "text"],
["active", "bool"], ["avatar", "text"], ["last_seen", "ts"], ["sections_json", "json"],
["servers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "user_interface_bindings", columns: [
["id", "text"], ["user_id", "text"], ["server_id", "int"], ["interface_name", "text"],
["interface_type", "text"], ["peer_public_key", "text"], ["peer_name", "text"],
["comment", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "alert_groups", columns: [
["id", "text"], ["name", "text"], ["combine_mode", "text"], ["enabled", "bool"],
["cooldown_override", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "alert_rules", columns: [
["id", "text"], ["name", "text"], ["type", "text"], ["target", "text"], ["condition", "text"],
["severity", "text"], ["enabled", "bool"], ["cooldown", "text"], ["rule_chat_id", "text"],
["confirm_stability_sec", "int"], ["recovery_mode", "text"], ["recovery_stability_sec", "int"],
["group_id", "text"], ["created_at", "ts"], ["updated_at", "ts"],
]},
{ table: "alert_rule_targets", columns: [
["id", "text"], ["rule_id", "text"], ["target", "text"], ["sort_index", "int"],
]},
{ table: "alert_rule_conditions", columns: [
["id", "text"], ["rule_id", "text"], ["condition_line", "text"], ["sort_index", "int"],
]},
{ table: "alert_engine_state", columns: [
["scope_key", "text"], ["last_fired_at", "text"], ["last_payload_hash", "text"],
]},
{ table: "alert_engine_prev_live", columns: [
["kind", "text"], ["payload_json", "json"], ["updated_at", "ts"],
]},
{ table: "alert_engine_confirm_pending", columns: [
["rule_id", "text"], ["payload_hash", "text"], ["since_at", "ts"],
]},
{ table: "alert_history", columns: [
["id", "text"], ["rule_id", "text"], ["group_id", "text"], ["rule_name", "text"],
["severity", "text"], ["message", "text"], ["sent_ok", "bool"], ["fired_at", "ts"],
]},
{ table: "alert_outbox", columns: [
["id", "text"], ["dedupe_key", "text"], ["channel", "text"], ["status", "text"],
["retry_count", "int"], ["max_retries", "int"], ["next_attempt_at", "ts"],
["payload_json", "json"], ["created_at", "ts"], ["sent_at", "ts"], ["last_error", "text"],
]},
{ table: "certificate_issue_jobs", columns: [
["id", "text"], ["status", "text"], ["step", "text"], ["source", "text"],
["server_id", "bigint-id"], ["cert_name", "text"], ["domain_names", "json"],
["key_type", "text"], ["trust_store", "text"], ["requested_at", "ts"],
["started_at", "ts"], ["finished_at", "ts"], ["error", "text"],
]},
{ table: "backup_entries", columns: [
["id", "text"], ["server_id", "bigint-id"], ["server_name", "text"], ["filename", "text"],
["size_bytes", "int"], ["kind", "text"], ["notes", "text"], ["created_at", "ts"],
]},
{ table: "scheduler_runs", timeCol: "started_at", retentionDays: 30, columns: [
["id", "text"], ["job_key", "text"], ["started_at", "ts"], ["finished_at", "ts"],
["status", "text"], ["error", "text"], ["duration_ms", "int"], ["result_json", "json-null"],
]},
{ table: "events", timeCol: "created_at", retentionDays: 30, columns: [
["id", "text"], ["created_at", "ts"], ["level", "text"], ["event_type", "text"],
["source_module", "text"], ["title", "text"], ["message", "text"],
["entity_type", "text"], ["entity_id", "text"], ["payload_json", "json-null"],
]},
{ table: "internet_path_snapshots", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
["id", "int"], ["sampled_at", "ts"], ["payload_json", "json"],
]},
]
function parseTs(value: unknown, strict: boolean, rejects: string[], ctx: string): string | null {
if (value == null || value === "") return null
const s = String(value)
try {
if (/^\d{4}-\d{2}-\d{2}T/.test(s)) return new Date(s).toISOString()
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) {
return new Date(`${s.replace(" ", "T")}Z`).toISOString()
}
const d = new Date(s)
if (!Number.isNaN(d.getTime())) return d.toISOString()
} catch {
/* fallthrough */
}
const msg = `${ctx}: неразобранный timestamp ${s}`
if (strict) throw new Error(msg)
rejects.push(msg)
return null
}
function parseDate(value: unknown): string | null {
if (value == null || value === "") return null
return String(value).slice(0, 10)
}
function parseBool(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "true"
}
function parseJson(value: unknown, fallback: unknown): unknown {
if (value == null || value === "") return fallback
if (typeof value !== "string") return value
try {
return JSON.parse(value) as unknown
} catch {
return fallback
}
}
function coerce(kind: ColKind, value: unknown, strict: boolean, rejects: string[], ctx: string): unknown {
switch (kind) {
case "ts":
return parseTs(value, strict, rejects, ctx)
case "date":
return parseDate(value)
case "bool":
return parseBool(value)
case "json":
return parseJson(value, [])
case "json-null":
return value == null || value === "" ? null : parseJson(value, null)
case "bigint-id": {
const t = String(value ?? "").trim()
if (!t) return null
const n = Number(t)
if (!Number.isFinite(n)) {
const msg = `${ctx}: server_id ${t}`
if (strict) throw new Error(msg)
rejects.push(msg)
return null
}
return n
}
case "int":
if (value == null || value === "") return null
return Number(value)
case "num":
if (value == null || value === "") return null
return Number(value)
case "text":
return value == null ? "" : String(value)
default:
return value == null ? null : String(value)
}
}
function sqliteTableExists(sqlite: Database.Database, name: string): boolean {
const row = sqlite.prepare(
`SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?`,
).get(name) as { ok?: number } | undefined
return Boolean(row)
}
async function setval(pool: Pool, table: string): Promise<void> {
await pool.query(
`SELECT setval(
pg_get_serial_sequence($1, 'id'),
GREATEST(COALESCE((SELECT MAX(id) FROM ${table}), 1), 1),
true
)`,
[table],
)
}
async function copyTable(
sqlite: Database.Database,
pool: Pool,
spec: TableCopy,
opts: { strict: boolean; fullHistory: boolean; rejects: string[] },
): Promise<{ sqlite: number; copied: number; skipped: number }> {
if (!sqliteTableExists(sqlite, spec.table)) {
return { sqlite: 0, copied: 0, skipped: 0 }
}
let where = ""
if (spec.timeCol && spec.retentionDays && !opts.fullHistory) {
const cutoff = new Date(Date.now() - spec.retentionDays * 86400_000).toISOString()
where = ` WHERE ${spec.timeCol} >= '${cutoff.replace("T", " ").slice(0, 19)}' OR ${spec.timeCol} >= '${cutoff}'`
}
const total = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}${where}`).get() as { c: number }).c
const part = specForParent(spec.table)
const cols = spec.columns.map(([c]) => c)
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ")
const conflictSql = spec.upsert
? `ON CONFLICT (id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c} = EXCLUDED.${c}`).join(", ")}`
: `ON CONFLICT DO NOTHING`
const insertSql = `INSERT INTO ${spec.table} (${cols.join(", ")}) VALUES (${placeholders}) ${conflictSql}`
let copied = 0
let skipped = 0
const stmt = sqlite.prepare(`SELECT * FROM ${spec.table}${where}`)
const batch: unknown[][] = []
const flush = async () => {
if (batch.length === 0) return
const client = await pool.connect()
try {
await client.query("BEGIN")
for (const values of batch) {
await client.query(insertSql, values)
copied += 1
}
await client.query("COMMIT")
} catch (err) {
await client.query("ROLLBACK")
throw err
} finally {
client.release()
batch.length = 0
}
}
for (const row of stmt.iterate() as Iterable<Record<string, unknown>>) {
try {
if (part && spec.timeCol) {
const raw = row[spec.timeCol]
const ts = spec.columns.find((c) => c[0] === spec.timeCol)?.[1] === "date"
? `${String(raw).slice(0, 10)}T00:00:00Z`
: parseTs(raw, false, opts.rejects, spec.table)
if (ts) await ensurePartitionFor(pool, spec.table, part.kind, new Date(ts))
}
const values = spec.columns.map(([col, kind]) =>
coerce(kind, row[col], opts.strict, opts.rejects, `${spec.table}.${col}`),
)
if (spec.table === "certificate_issue_jobs" && values[4] == null) {
skipped += 1
continue
}
batch.push(values)
if (batch.length >= 200) await flush()
} catch (err) {
skipped += 1
const msg = `${spec.table}: ${err instanceof Error ? err.message : String(err)}`
opts.rejects.push(msg)
if (opts.strict) throw err
}
}
await flush()
if (spec.identity) {
try {
await setval(pool, spec.table)
} catch {
/* partitioned identity sequence name may differ */
}
}
return { sqlite: total, copied, skipped }
}
export function sqliteFileLooksPresent(path: string): boolean {
if (!existsSync(path) || path === ":memory:") return false
try {
const buf = readFileSync(path)
return buf.subarray(0, 16).toString("utf8").startsWith("SQLite format 3")
} catch {
return false
}
}
export async function shouldImportSqlite(pool: Pool, sqlitePath: string): Promise<boolean> {
if (!sqliteFileLooksPresent(sqlitePath)) return false
const marker = await pool.query<{ sqlite_imported_at: string | null }>(
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
)
if (marker.rows[0]?.sqlite_imported_at) return false
const servers = await pool.query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM servers`)
if (Number(servers.rows[0]?.c ?? 0) > 0) return false
return true
}
export async function importSqliteToPostgres(
pool: Pool,
sqlitePath: string,
opts?: { dryRun?: boolean; strict?: boolean; fullHistory?: boolean },
): Promise<ImportReport> {
const started = Date.now()
const rejects: string[] = []
const sqlite = new Database(sqlitePath, { readonly: true, fileMustExist: true })
try {
sqlite.pragma("journal_mode = WAL")
} catch {
/* readonly */
}
const sha = createHash("sha256").update(readFileSync(sqlitePath)).digest("hex")
const report: ImportReport = {
sqlitePath,
sqliteSha256: sha,
tables: {},
rejects,
durationMs: 0,
}
const fullHistory = opts?.fullHistory ?? env.sqliteImportFullHistory
const strict = opts?.strict ?? true
if (opts?.dryRun) {
for (const spec of TABLES) {
if (!sqliteTableExists(sqlite, spec.table)) {
report.tables[spec.table] = { sqlite: 0, copied: 0, skipped: 0 }
continue
}
const c = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}`).get() as { c: number }).c
report.tables[spec.table] = { sqlite: c, copied: 0, skipped: 0 }
}
sqlite.close()
report.durationMs = Date.now() - started
return report
}
for (const spec of TABLES) {
report.tables[spec.table] = await copyTable(sqlite, pool, spec, { strict, fullHistory, rejects })
}
sqlite.close()
await pool.query(
`UPDATE data_migration
SET sqlite_imported_at = now(), sqlite_path = $1, sqlite_sha256 = $2, report_json = $3::jsonb
WHERE id = 1`,
[sqlitePath, sha, JSON.stringify(report.tables)],
)
report.durationMs = Date.now() - started
if (strict && rejects.length > 0) {
throw new Error(`SQLite import strict: ${rejects.length} rejects\n${rejects.slice(0, 20).join("\n")}`)
}
return report
}
+57 -3
View File
@@ -1,7 +1,10 @@
import Fastify, { type FastifyInstance } from "fastify"
import Fastify, { type FastifyError, type FastifyInstance } from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { monitorEventLoopDelay } from "node:perf_hooks"
import { env } from "./config.js"
import { initDatabase } from "./db/bootstrap.js"
import { closePool } from "./db/index.js"
import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
@@ -10,6 +13,7 @@ import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
import trafficFlowRoutes from "./routes/traffic-flow.js"
import serversApiPingRoutes from "./routes/servers-api-ping.js"
import uptimeRoutes from "./routes/uptime.js"
import networkRoutes from "./routes/network.js"
@@ -24,7 +28,13 @@ import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
import firewallRoutes from "./routes/firewall.js"
import usersRoutes from "./routes/users.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
eventLoopDelay.enable()
export async function buildApp(opts?: {
logger?: boolean
@@ -33,7 +43,7 @@ export async function buildApp(opts?: {
const usePrettyLogger =
opts?.logger !== false && process.env.NODE_ENV !== "production"
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
bodyLimit: 2 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger:
opts?.logger === false
@@ -55,6 +65,18 @@ export async function buildApp(opts?: {
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
app.setErrorHandler((error: FastifyError, request, reply) => {
const status = typeof error.statusCode === "number" && error.statusCode >= 400
? error.statusCode
: 500
if (status >= 500) {
request.log.error(error)
return reply.status(status).send({ error: "Внутренняя ошибка сервера" })
}
const message = error instanceof Error ? error.message : "Ошибка запроса"
return reply.status(status).send({ error: message })
})
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
@@ -66,6 +88,8 @@ export async function buildApp(opts?: {
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
eventLoopDelayMs: Math.round(eventLoopDelay.mean / 1e6),
flowWorker: getFlowWorkerHealth(),
}))
app.get("/api/auth/config", async () => ({
@@ -91,6 +115,7 @@ export async function buildApp(opts?: {
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(trafficFlowRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
@@ -105,11 +130,15 @@ export async function buildApp(opts?: {
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" })
if (opts?.startScheduler !== false) {
refreshScheduler()
await refreshScheduler()
await startTrafficFlowListener()
app.addHook("onClose", async () => {
stopScheduler()
stopTrafficFlowListener()
})
}
@@ -122,7 +151,32 @@ const isMain =
if (isMain) {
try {
await initDatabase()
const app = await buildApp()
let shuttingDown = false
const shutdown = async (code: number) => {
if (shuttingDown) return
shuttingDown = true
try {
stopTrafficFlowListener()
await app.close()
await closePool()
} catch (err) {
console.error(err)
} finally {
process.exit(code)
}
}
process.on("SIGTERM", () => { void shutdown(0) })
process.on("SIGINT", () => { void shutdown(0) })
process.on("uncaughtException", (err) => {
console.error(err)
void shutdown(1)
})
process.on("unhandledRejection", (reason) => {
console.error(reason)
void shutdown(1)
})
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
+23
View File
@@ -17,6 +17,14 @@ assert.equal(
permissionForRequest("GET", "/api/system/database/backup"),
"mm:settings:admin",
)
assert.equal(
permissionForRequest("GET", "/api/traffic/servers/1/live"),
"mm:traffic:read",
)
assert.equal(
permissionForRequest("POST", "/api/traffic/flow/purge"),
"mm:traffic:write",
)
assert.equal(
permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read",
@@ -29,5 +37,20 @@ assert.equal(
permissionForRequest("POST", "/api/wireguard/interfaces"),
"mm:network:write",
)
assert.equal(
permissionForRequest("GET", "/api/firewall/all"),
"mm:network:read",
)
assert.equal(
permissionForRequest("GET", "/api/users"),
"mm:users:read",
)
assert.equal(
permissionForRequest("POST", "/api/users"),
"mm:users:write",
)
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
console.log("permissions.test.ts: ok")
+18 -4
View File
@@ -17,6 +17,9 @@ export function hasPermission(
required: string,
): boolean {
if (granted.includes(required)) return true
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
return true
}
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
@@ -49,8 +52,17 @@ const RULES: Rule[] = [
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
match: (p) => p.startsWith("/api/users"),
permission: "mm:users:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/users"),
permission: "mm:users:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
permission: "mm:dashboard:read",
},
{
@@ -142,7 +154,8 @@ const RULES: Rule[] = [
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard"),
p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
permission: "mm:network:read",
},
{
@@ -154,7 +167,8 @@ const RULES: Rule[] = [
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard"),
p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
permission: "mm:network:write",
},
]
@@ -24,18 +24,7 @@ export type ListEventsParams = {
to?: string
}
function parsePayload(payloadJson: string | null): Record<string, unknown> {
if (!payloadJson) return {}
try {
const parsed = JSON.parse(payloadJson) as unknown
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
// keep backwards compatibility with malformed legacy payloads
}
return {}
}
import { parseJsonObject } from "../../../db/json.js"
function mapEventRow(row: typeof events.$inferSelect): EventItem {
return {
@@ -48,13 +37,13 @@ function mapEventRow(row: typeof events.$inferSelect): EventItem {
message: row.message,
entityType: row.entityType ?? null,
entityId: row.entityId ?? null,
payload: parsePayload(row.payloadJson ?? null),
payload: parseJsonObject(row.payloadJson),
}
}
export function insertEventsBatch(items: EventInsertInput[]) {
export async function insertEventsBatch(items: EventInsertInput[]) {
if (items.length === 0) return
db.insert(events)
await db.insert(events)
.values(
items.map((item) => ({
id: item.id,
@@ -66,25 +55,23 @@ export function insertEventsBatch(items: EventInsertInput[]) {
message: item.message,
entityType: item.entityType ?? null,
entityId: item.entityId ?? null,
payloadJson: item.payload ? JSON.stringify(item.payload) : null,
payloadJson: item.payload ?? null,
})),
)
.run()
}
export function listEvents(params: ListEventsParams): EventItem[] {
export async function listEvents(params: ListEventsParams): Promise<EventItem[]> {
const where = and(
params.level ? eq(events.level, params.level) : undefined,
params.sourceModule ? eq(events.sourceModule, params.sourceModule) : undefined,
params.from ? gte(events.createdAt, params.from) : undefined,
params.to ? lte(events.createdAt, params.to) : undefined,
)
const rows = db
const rows = await db
.select()
.from(events)
.where(where)
.orderBy(desc(events.createdAt))
.limit(params.limit)
.all()
return rows.map(mapEventRow)
}
@@ -23,17 +23,17 @@ function normalizeEventInput(input: AppendEventBody): EventInsertInput {
}
}
export function appendEvent(input: AppendEventBody) {
export async function appendEvent(input: AppendEventBody) {
const parsed = appendEventSchema.parse(input)
insertEventsBatch([normalizeEventInput(parsed)])
await insertEventsBatch([normalizeEventInput(parsed)])
}
export function appendEvents(inputs: AppendEventBody[]) {
export async function appendEvents(inputs: AppendEventBody[]) {
const rows = inputs.map((entry) => normalizeEventInput(appendEventSchema.parse(entry)))
insertEventsBatch(rows)
await insertEventsBatch(rows)
}
export function readEvents(query: Partial<ListEventsQuery>): EventItem[] {
export async function readEvents(query: Partial<ListEventsQuery>): Promise<EventItem[]> {
const parsed = listEventsQuerySchema.parse(query)
return listEvents(parsed)
return await listEvents(parsed)
}
@@ -1,29 +1,24 @@
import { parseJsonArray } from "../../../db/json.js"
import type { ServerRead, WanUplinkRead } from "../../../types/server.js"
import type { ServerRow, SnapshotRow } from "../repository/servers-repository.js"
function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] {
if (raw == null || raw === "") return []
try {
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
const out: WanUplinkRead[] = []
for (const row of data) {
if (typeof row !== "object" || row === null) continue
const r = row as Record<string, unknown>
out.push({
id: typeof r.id === "string" ? r.id : "",
name: typeof r.name === "string" ? r.name : "",
isp: typeof r.isp === "string" ? r.isp : "",
iface: typeof r.iface === "string" ? r.iface : "",
ip: typeof r.ip === "string" ? r.ip : "",
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
})
}
return out
} catch {
return []
function parseWanUplinksJson(raw: unknown): WanUplinkRead[] {
const data = parseJsonArray(raw)
const out: WanUplinkRead[] = []
for (const row of data) {
if (typeof row !== "object" || row === null) continue
const r = row as Record<string, unknown>
out.push({
id: typeof r.id === "string" ? r.id : "",
name: typeof r.name === "string" ? r.name : "",
isp: typeof r.isp === "string" ? r.isp : "",
iface: typeof r.iface === "string" ? r.iface : "",
ip: typeof r.ip === "string" ? r.ip : "",
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
})
}
return out
}
export function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
@@ -1,53 +1,57 @@
import { desc, eq } from "drizzle-orm"
import { db } from "../../../db/index.js"
import { serverSnapshots, servers } from "../../../db/schema.js"
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
export type ServerRow = typeof servers.$inferSelect
export type SnapshotRow = typeof serverSnapshots.$inferSelect
export function listServerRows(): ServerRow[] {
return db.select().from(servers).all()
export async function listServerRows(): Promise<ServerRow[]> {
return await db.select().from(servers)
}
export function getServerRowById(id: number): ServerRow | undefined {
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
export async function getServerRowById(id: number): Promise<ServerRow | undefined> {
const rows = await db.select().from(servers).where(eq(servers.id, id)).limit(1)
return rows[0]
}
export function createServerRow(
export async function createServerRow(
values: Omit<typeof servers.$inferInsert, "id">,
): ServerRow {
const [inserted] = db.insert(servers).values(values).returning().all()
): Promise<ServerRow> {
const [inserted] = await db.insert(servers).values(values).returning()
invalidateFlowCatalogCache()
return inserted
}
export function updateServerRowById(
export async function updateServerRowById(
id: number,
values: Partial<ServerRow>,
): ServerRow {
const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all()
): Promise<ServerRow> {
const [updated] = await db.update(servers).set(values).where(eq(servers.id, id)).returning()
invalidateFlowCatalogCache()
return updated
}
export function deleteServerRowById(id: number): void {
db.delete(servers).where(eq(servers.id, id)).run()
export async function deleteServerRowById(id: number): Promise<void> {
await db.delete(servers).where(eq(servers.id, id))
invalidateFlowCatalogCache()
}
export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] {
export async function listSnapshotsByServerId(serverId: number, limit: number): Promise<SnapshotRow[]> {
return db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt))
.limit(limit)
.all()
}
export function getLatestSnapshot(serverId: number): SnapshotRow | undefined {
return db
export async function getLatestSnapshot(serverId: number): Promise<SnapshotRow | undefined> {
const rows = await db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt))
.limit(1)
.all()[0]
return rows[0]
}
@@ -11,45 +11,46 @@ import {
import { toServerRead } from "../mapper/servers-mapper.js"
import type { ServerCreate, ServerRead, ServerUpdate, SnapshotRead } from "../../../types/server.js"
export function listServersRead(): ServerRead[] {
return listServerRows().map((server) => toServerRead(server, getLatestSnapshot(server.id)))
export async function listServersRead(): Promise<ServerRead[]> {
const rows = await listServerRows()
return Promise.all(rows.map(async (server) => toServerRead(server, await getLatestSnapshot(server.id))))
}
export function getServerReadById(id: number): ServerRead | undefined {
const row = getServerRowById(id)
export async function getServerReadById(id: number): Promise<ServerRead | undefined> {
const row = await getServerRowById(id)
if (!row) return undefined
return toServerRead(row, getLatestSnapshot(row.id))
return toServerRead(row, await getLatestSnapshot(row.id))
}
export function createServer(input: ServerCreate): ServerRead {
export async function createServer(input: ServerCreate): Promise<ServerRead> {
const now = new Date().toISOString()
const { wanUplinks, ...rest } = input
const inserted = createServerRow({
const inserted = await createServerRow({
...rest,
wanUplinks: JSON.stringify(wanUplinks ?? []),
wanUplinks: wanUplinks ?? [],
createdAt: now,
updatedAt: now,
})
return toServerRead(inserted, undefined)
}
export function updateServer(id: number, input: ServerUpdate): ServerRead {
export async function updateServer(id: number, input: ServerUpdate): Promise<ServerRead> {
const { wanUplinks, ...rest } = input
const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() }
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) setPayload[k] = v
}
if (wanUplinks !== undefined) {
setPayload.wanUplinks = JSON.stringify(wanUplinks)
setPayload.wanUplinks = wanUplinks
}
const updated = updateServerRowById(id, setPayload as never)
return toServerRead(updated, getLatestSnapshot(updated.id))
const updated = await updateServerRowById(id, setPayload as never)
return toServerRead(updated, await getLatestSnapshot(updated.id))
}
export function deleteServer(id: number): void {
deleteServerRowById(id)
export async function deleteServer(id: number): Promise<void> {
await deleteServerRowById(id)
}
export function listServerSnapshots(id: number, limit: number): SnapshotRead[] {
return listSnapshotsByServerId(id, limit).map(toSnapshotRead)
export async function listServerSnapshots(id: number, limit: number): Promise<SnapshotRead[]> {
return (await listSnapshotsByServerId(id, limit)).map(toSnapshotRead)
}
@@ -0,0 +1,74 @@
import assert from "node:assert/strict"
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
import { withPgOrSkip } from "../../test/pg.js"
import { dbQuery } from "../../db/index.js"
assert.throws(
() => normalizeBindingPeer("wg", ""),
(err: unknown) => err instanceof PeerBindError && err.status === 400,
"WG без ключа — 400",
)
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
if (!(await withPgOrSkip())) {
console.log("users bindings unique+cascade tests skip")
process.exit(0)
}
await dbQuery(`
INSERT INTO servers (id, name, host) VALUES (91001, 'jh-bind-test', '10.0.0.1')
ON CONFLICT (id) DO NOTHING
`)
await dbQuery(`
INSERT INTO app_users (id, name, login)
VALUES ('u-bind-1', 'A', 'a.bind.test'), ('u-bind-2', 'B', 'b.bind.test')
ON CONFLICT (id) DO NOTHING
`)
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = 91001`)
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b-bind-1', 'u-bind-1', 91001, 'gre-office', 'gre')
`)
let uniqueIface = false
try {
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b-bind-2', 'u-bind-2', 91001, 'gre-office', 'gre')
`)
} catch {
uniqueIface = true
}
assert.equal(uniqueIface, true, "один интерфейс на сервере — один пользователь")
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg-bind-1', 'u-bind-1', 91001, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
`)
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg-bind-2', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
`)
let uniquePeer = false
try {
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
VALUES ('wg-bind-3', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-aaa')
`)
} catch {
uniquePeer = true
}
assert.equal(uniquePeer, true, "один пир — один пользователь")
await dbQuery(`DELETE FROM app_users WHERE id = 'u-bind-1'`)
const leftover = await dbQuery<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM user_interface_bindings WHERE server_id = 91001`,
)
assert.equal(leftover.rows[0]?.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
await dbQuery(`DELETE FROM app_users WHERE id IN ('u-bind-1', 'u-bind-2')`)
await dbQuery(`DELETE FROM servers WHERE id = 91001`)
console.log("users bindings unique+cascade tests ok")
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from "./iface-type.js"
assert.equal(mapRosInterfaceType("ether"), "ether")
assert.equal(mapRosInterfaceType("ethernet"), "ether")
assert.equal(mapRosInterfaceType("GRE"), "gre")
assert.equal(mapRosInterfaceType("gre-tunnel"), "gre")
assert.equal(mapRosInterfaceType("gre6-tunnel"), "gre")
assert.equal(mapRosInterfaceType("wg"), "wg")
assert.equal(mapRosInterfaceType("wireguard"), "wg")
assert.equal(mapRosInterfaceType("vlan"), "other")
assert.equal(mapRosInterfaceType(""), "other")
assert.equal(mapRosInterfaceType("", "gre-tunnel1"), "gre")
assert.equal(mapRosInterfaceType("", "MSK-DC"), "other")
assert.equal(mapRosInterfaceType("gre-tunnel", "MSK-DC"), "gre")
assert.equal(mapRosInterfaceType("", "wg-msk-spb"), "wg")
assert.equal(mapRosInterfaceType("", "ether1"), "ether")
const parsed = parseRawInterfaces(JSON.stringify([
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
{ name: "gre-office", type: "gre-tunnel", running: "false", disabled: "false" },
{ name: "wg-msk", type: "wg", running: true, disabled: false },
{ name: "MSK-DC", type: "gre-tunnel", running: true, disabled: false },
{ name: "", type: "ether" },
]))
assert.equal(parsed.length, 4)
assert.equal(parsed[0]?.type, "ether")
assert.equal(parsed[0]?.running, true)
assert.equal(parsed[1]?.type, "gre")
assert.equal(parsed[1]?.running, false)
assert.equal(parsed[2]?.type, "wg")
assert.equal(parsed[3]?.type, "gre")
assert.equal(parseRawInterfaces("not-json").length, 0)
assert.equal(parseRawInterfaces(null).length, 0)
assert.equal(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE", message: "UNIQUE" }), true)
assert.equal(isUniqueConstraintError({ message: "UNIQUE constraint failed: t.c" }), true)
assert.equal(isUniqueConstraintError({ message: "other" }), false)
console.log("users iface-type tests ok")
+60
View File
@@ -0,0 +1,60 @@
export type InterfaceType = "ether" | "gre" | "wg" | "other"
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
const t = String(raw ?? "").trim().toLowerCase()
if (t === "ether" || t === "ethernet" || t.startsWith("ether")) return "ether"
// RouterOS /interface type for GRE is "gre-tunnel" (also gre, gre6, gre6-tunnel)
if (t === "gre" || t.startsWith("gre-") || t.startsWith("gre6")) return "gre"
if (t === "wg" || t === "wireguard") return "wg"
const n = String(name ?? "").trim().toLowerCase()
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
return "other"
}
export interface ParsedRosIface {
name: string
type: InterfaceType
running: boolean
disabled: boolean
}
function asBool(raw: unknown): boolean {
if (typeof raw === "boolean") return raw
const s = String(raw ?? "").trim().toLowerCase()
return s === "true" || s === "yes" || s === "1"
}
export function parseRawInterfaces(json: unknown): ParsedRosIface[] {
if (json == null || json === "") return []
try {
const parsed = typeof json === "string" ? JSON.parse(json) as unknown : json
const arr = Array.isArray(parsed) ? parsed : []
const out: ParsedRosIface[] = []
for (const item of arr) {
if (!item || typeof item !== "object") continue
const rec = item as Record<string, unknown>
const name = String(rec.name ?? "").trim()
if (!name) continue
out.push({
name,
type: mapRosInterfaceType(String(rec.type ?? ""), name),
running: asBool(rec.running),
disabled: asBool(rec.disabled),
})
}
return out
} catch {
return []
}
}
export function isUniqueConstraintError(err: unknown): boolean {
if (!err || typeof err !== "object") return false
const rec = err as { code?: unknown; message?: unknown }
const code = String(rec.code ?? "")
const msg = String(rec.message ?? "")
return code === "23505" || code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
}
+44
View File
@@ -0,0 +1,44 @@
import type { InterfaceType } from "./iface-type.js"
export class PeerBindError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message)
this.name = "PeerBindError"
}
}
export function truncPeerKey(key: string): string {
const k = key.trim()
if (k.length <= 20) return k
return `${k.slice(0, 8)}${k.slice(-8)}`
}
export function peerDisplayName(opts: {
publicKey: string
name?: string | null
comment?: string | null
}): string {
const name = (opts.name ?? "").trim()
if (name) return name
const comment = (opts.comment ?? "").trim()
if (comment) return comment
return truncPeerKey(opts.publicKey)
}
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
export function normalizeBindingPeer(
type: InterfaceType,
peerPublicKey: string | undefined,
): string {
const key = (peerPublicKey ?? "").trim()
if (type === "wg") {
if (!key) {
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
}
return key
}
return ""
}
@@ -0,0 +1,87 @@
import { and, count, eq } from "drizzle-orm"
import { db } from "../../../db/index.js"
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
export type AppUserRow = typeof appUsers.$inferSelect
export type BindingRow = typeof userInterfaceBindings.$inferSelect
export async function listUserRows(): Promise<AppUserRow[]> {
return await db.select().from(appUsers)
}
export async function getUserRowById(id: string): Promise<AppUserRow | undefined> {
const rows = await db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1)
return rows[0]
}
export async function getUserRowByLogin(login: string): Promise<AppUserRow | undefined> {
const rows = await db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1)
return rows[0]
}
export async function createUserRow(values: typeof appUsers.$inferInsert): Promise<AppUserRow> {
const [inserted] = await db.insert(appUsers).values(values).returning()
invalidateFlowCatalogCache()
return inserted
}
export async function updateUserRowById(
id: string,
values: Partial<AppUserRow>,
): Promise<AppUserRow> {
const [updated] = await db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning()
invalidateFlowCatalogCache()
return updated
}
export async function deleteUserRowById(id: string): Promise<void> {
await db.delete(appUsers).where(eq(appUsers.id, id))
invalidateFlowCatalogCache()
}
export async function listBindingRows(): Promise<BindingRow[]> {
return await db.select().from(userInterfaceBindings)
}
export async function listBindingRowsByUser(userId: string): Promise<BindingRow[]> {
return await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId))
}
export async function getBindingRowById(id: string): Promise<BindingRow | undefined> {
const rows = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1)
return rows[0]
}
export async function getBindingByServerIfacePeer(
serverId: number,
interfaceName: string,
peerPublicKey = "",
): Promise<BindingRow | undefined> {
const rows = await db
.select()
.from(userInterfaceBindings)
.where(and(
eq(userInterfaceBindings.serverId, serverId),
eq(userInterfaceBindings.interfaceName, interfaceName),
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
))
.limit(1)
return rows[0]
}
export async function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): Promise<BindingRow> {
const [inserted] = await db.insert(userInterfaceBindings).values(values).returning()
invalidateFlowCatalogCache()
return inserted
}
export async function deleteBindingRowById(id: string): Promise<void> {
await db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id))
invalidateFlowCatalogCache()
}
export async function countUserRows(): Promise<number> {
const rows = await db.select({ n: count() }).from(appUsers)
return rows[0]?.n ?? 0
}
@@ -0,0 +1,340 @@
import { randomUUID } from "node:crypto"
import { desc, eq } from "drizzle-orm"
import type {
AppUserCreate,
AppUserRead,
AppUserUpdate,
CatalogInterface,
InterfaceType,
SectionPerm,
ServerPerm,
UserBinding,
UserBindingCreate,
} from "@mmapp/contracts/users"
import { db } from "../../../db/index.js"
import { parseJsonArray } from "../../../db/json.js"
import { servers, trafficSamples } from "../../../db/schema.js"
import {
createBindingRow,
createUserRow,
deleteBindingRowById,
deleteUserRowById,
getBindingByServerIfacePeer,
getBindingRowById,
getUserRowById,
getUserRowByLogin,
listBindingRows,
listBindingRowsByUser,
listUserRows,
updateUserRowById,
type AppUserRow,
type BindingRow,
} from "../repository/users-repository.js"
import { getLatestSnapshot } from "../../servers/repository/servers-repository.js"
import {
isUniqueConstraintError,
mapRosInterfaceType,
parseRawInterfaces,
} from "../iface-type.js"
import {
normalizeBindingPeer,
PeerBindError,
peerDisplayName,
} from "../peer-bind.js"
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
export class UsersServiceError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message)
this.name = "UsersServiceError"
}
}
function asPermArray<T>(raw: unknown, fallback: T[]): T[] {
const arr = parseJsonArray(raw)
return arr.length ? arr as T[] : fallback
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
return parts.map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
}
async function serverMeta(serverId: number): Promise<{ name: string; site: string; country: string }> {
const rows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
const row = rows[0]
return {
name: row?.name || row?.host || String(serverId),
site: row?.site || "—",
country: row?.country || "UN",
}
}
async function toBindingDto(row: BindingRow): Promise<UserBinding> {
const meta = await serverMeta(row.serverId)
return {
id: row.id,
userId: row.userId,
serverId: row.serverId,
serverName: meta.name,
serverSite: meta.site,
serverCountry: meta.country,
interfaceName: row.interfaceName,
interfaceType: row.interfaceType,
peerPublicKey: row.peerPublicKey ?? "",
peerName: row.peerName ?? "",
comment: row.comment,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
async function toUserDto(row: AppUserRow, bindings: BindingRow[]): Promise<AppUserRead> {
return {
id: row.id,
name: row.name,
login: row.login,
email: row.email,
role: row.role,
active: Boolean(row.active),
avatar: row.avatar,
lastSeen: row.lastSeen ?? null,
sections: asPermArray<SectionPerm>(row.sectionsJson, []),
servers: asPermArray<ServerPerm>(row.serversJson, []),
bindings: await Promise.all(bindings.map((b) => toBindingDto(b))),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export async function listUsers(): Promise<AppUserRead[]> {
const users = await listUserRows()
const allBindings = await listBindingRows()
const byUser = new Map<string, BindingRow[]>()
for (const b of allBindings) {
const arr = byUser.get(b.userId) ?? []
arr.push(b)
byUser.set(b.userId, arr)
}
return Promise.all(users.map((u) => toUserDto(u, byUser.get(u.id) ?? [])))
}
export async function getUserById(id: string): Promise<AppUserRead | undefined> {
const row = await getUserRowById(id)
if (!row) return undefined
return await toUserDto(row, await listBindingRowsByUser(id))
}
export async function createUser(input: AppUserCreate): Promise<AppUserRead> {
const login = input.login.trim()
if (await getUserRowByLogin(login)) {
throw new UsersServiceError("Логин уже занят", 409)
}
const now = new Date().toISOString()
const row = await createUserRow({
id: randomUUID(),
name: input.name.trim(),
login,
email: input.email.trim(),
role: input.role ?? "viewer",
active: input.active ?? true,
avatar: (input.avatar ?? "").trim() || initials(input.name),
lastSeen: input.lastSeen ?? null,
sectionsJson: input.sections ?? [],
serversJson: input.servers ?? [],
createdAt: now,
updatedAt: now,
})
return await toUserDto(row, [])
}
export async function updateUser(id: string, input: AppUserUpdate): Promise<AppUserRead> {
const existing = await getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
if (input.login != null) {
const other = await getUserRowByLogin(input.login.trim())
if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409)
}
const patch: Partial<AppUserRow> = { updatedAt: new Date().toISOString() }
if (input.name != null) patch.name = input.name.trim()
if (input.login != null) patch.login = input.login.trim()
if (input.email != null) patch.email = input.email.trim()
if (input.role != null) patch.role = input.role
if (input.active != null) patch.active = input.active
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
if (input.sections != null) patch.sectionsJson = input.sections
if (input.servers != null) patch.serversJson = input.servers
const updated = await updateUserRowById(id, patch)
return await toUserDto(updated, await listBindingRowsByUser(id))
}
export async function deleteUser(id: string): Promise<void> {
const existing = await getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
await deleteUserRowById(id)
}
export async function addBinding(userId: string, input: UserBindingCreate): Promise<UserBinding> {
const user = await getUserRowById(userId)
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
const serverRows = await db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1)
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
const ifaceName = input.interfaceName.trim()
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
const type: InterfaceType = input.interfaceType ?? await inferIfaceType(input.serverId, ifaceName)
let peerPublicKey = ""
try {
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
} catch (err) {
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
throw err
}
const peerName = type === "wg"
? peerDisplayName({
publicKey: peerPublicKey,
name: input.peerName,
})
: ""
const taken = await getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
if (taken) {
throw new UsersServiceError(
type === "wg"
? "Этот пир уже привязан к другому пользователю"
: "Интерфейс уже привязан к другому пользователю",
409,
)
}
const now = new Date().toISOString()
try {
const row = await createBindingRow({
id: randomUUID(),
userId,
serverId: input.serverId,
interfaceName: ifaceName,
interfaceType: type,
peerPublicKey,
peerName,
comment: (input.comment ?? "").trim(),
createdAt: now,
updatedAt: now,
})
return await toBindingDto(row)
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new UsersServiceError(
type === "wg"
? "Этот пир уже привязан к другому пользователю"
: "Интерфейс уже привязан к другому пользователю",
409,
)
}
throw err
}
}
export async function removeBinding(userId: string, bindingId: string): Promise<void> {
const row = await getBindingRowById(bindingId)
if (!row || row.userId !== userId) {
throw new UsersServiceError("Привязка не найдена", 404)
}
await deleteBindingRowById(bindingId)
}
async function inferIfaceType(serverId: number, ifaceName: string): Promise<InterfaceType> {
const snap = await getLatestSnapshot(serverId)
const parsed = parseRawInterfaces(snap?.rawInterfaces)
const found = parsed.find((i) => i.name === ifaceName)
return found?.type ?? "other"
}
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
const snap = await getLatestSnapshot(serverId)
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
if (ifaces.length === 0) {
const lastRows = await db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.orderBy(desc(trafficSamples.sampledAt))
.limit(1)
const last = lastRows[0]
if (last) {
const rows = (await db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
running: trafficSamples.running,
disabled: trafficSamples.disabled,
})
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId)))
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
const seen = new Set<string>()
ifaces = []
for (const r of rows) {
if (seen.has(r.interfaceName)) continue
seen.add(r.interfaceName)
ifaces.push({
name: r.interfaceName,
type: mapRosInterfaceType("", r.interfaceName),
running: Boolean(r.running),
disabled: Boolean(r.disabled),
})
}
}
}
const bindings = (await listBindingRows()).filter((b) => b.serverId === serverId)
const usersById = new Map((await listUserRows()).map((u) => [u.id, u]))
const hasWg = ifaces.some((i) => i.type === "wg")
const wgLive = hasWg
? await listWireGuardPeersForCatalog(serverId)
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
const peersByIface = new Map<string, typeof wgLive.peers>()
for (const peer of wgLive.peers) {
const list = peersByIface.get(peer.interfaceName) ?? []
list.push(peer)
peersByIface.set(peer.interfaceName, list)
}
return ifaces.map((iface) => {
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
const base: CatalogInterface = {
name: iface.name,
type: iface.type,
running: iface.running,
disabled: iface.disabled,
boundUserId: ifaceBind?.userId ?? null,
boundUserLogin: owner?.login ?? null,
}
if (iface.type !== "wg") return base
const livePeers = peersByIface.get(iface.name) ?? []
return {
...base,
peersError: wgLive.error,
peers: livePeers.map((p) => {
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
const peerOwner = bind ? usersById.get(bind.userId) : undefined
return {
publicKey: p.publicKey,
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
comment: p.comment,
allowedIps: p.allowedIps,
latestHandshake: p.latestHandshake,
boundUserId: bind?.userId ?? null,
boundUserLogin: peerOwner?.login ?? null,
}
}),
}
}).sort((a, b) => a.name.localeCompare(b.name))
}
export { parseRawInterfaces, mapRosInterfaceType }
-1
View File
@@ -10,7 +10,6 @@ process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
process.env.CORS_ORIGIN = "http://localhost:3000"
process.env.DATABASE_PATH = ":memory:"
process.env.NODE_ENV = "test"
// Dynamic import after env is set
+12 -12
View File
@@ -20,14 +20,14 @@ import {
const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/alerts", async (_req, reply) => {
const tg = getTelegramPublic()
const tg = await getTelegramPublic()
const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim())
return reply.send({
telegram: { ...tg, connected },
groups: listAlertGroups(),
rules: listAlertRules(),
history: listMergedHistory(),
meta: getAlertsMeta(),
groups: await listAlertGroups(),
rules: await listAlertRules(),
history: await listMergedHistory(),
meta: await getAlertsMeta(),
})
})
@@ -38,7 +38,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
}
const groups =
parsed.data.groups ??
listAlertGroups().map((g) => ({
(await listAlertGroups()).map((g) => ({
id: g.id,
name: g.name,
combineMode: g.combineMode,
@@ -67,8 +67,8 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
chatId: r.chatId,
}))
replaceAlertsConfig({ groups, rules })
return reply.send({ ok: true, groups: listAlertGroups(), rules: listAlertRules() })
await replaceAlertsConfig({ groups, rules })
return reply.send({ ok: true, groups: await listAlertGroups(), rules: await listAlertRules() })
})
app.put("/alerts/telegram", async (req, reply) => {
@@ -76,7 +76,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const next = updateTelegramSettings({
const next = await updateTelegramSettings({
token: parsed.data.token,
chatId: parsed.data.chatId,
messageThreadId: parsed.data.messageThreadId,
@@ -90,9 +90,9 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const fromDb = getTelegramBotToken()
const fromDb = await getTelegramBotToken()
const token = (parsed.data.token?.trim() || fromDb).trim()
const pub = getTelegramPublic()
const pub = await getTelegramPublic()
const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim()
if (!token) {
return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" })
@@ -103,7 +103,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
const threadId =
parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1
? parsed.data.messageThreadId
: getTelegramMessageThreadIdForApi()
: await getTelegramMessageThreadIdForApi()
const text = parsed.data.rulePreview
? formatAlertRuleTestTelegramText(parsed.data.rulePreview)
: "MikroTik Manager — тест оповещений (Telegram)."
+11 -11
View File
@@ -61,7 +61,7 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
}
job.status = "done"
job.finishedAt = new Date().toISOString()
appendEvent({
await appendEvent({
level: job.failures.length > 0 ? "warning" : "info",
eventType: "backups.job.done",
sourceModule: "backups",
@@ -80,11 +80,11 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/backups", async (_req, reply) => {
return reply.send(listBackups())
return reply.send(await listBackups())
})
app.get("/backups/schedule", async (_req, reply) => {
return reply.send(getBackupScheduleSettings())
return reply.send(await getBackupScheduleSettings())
})
app.put("/backups/schedule", async (req, reply) => {
@@ -92,15 +92,15 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const result = updateBackupScheduleSettings(parsed.data)
refreshScheduler()
const result = await updateBackupScheduleSettings(parsed.data)
await refreshScheduler()
return reply.send(result)
})
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
const inputIds = req.body.serverIds.map((x) => String(x))
const notes = req.body.notes?.trim() || undefined
const existingServers = new Set(listServersRead().map((s) => String(s.id)))
const existingServers = new Set((await listServersRead()).map((s) => String(s.id)))
const ids = [...new Set(inputIds)].filter((id) => existingServers.has(id))
if (ids.length === 0) return reply.status(400).send({ error: "Не выбраны валидные серверы" })
@@ -115,7 +115,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
failures: [],
}
backupJobs.set(jobId, job)
appendEvent({
await appendEvent({
level: "info",
eventType: "backups.job.started",
sourceModule: "backups",
@@ -128,8 +128,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
notes: notes ?? null,
},
})
queueMicrotask(() => {
void processBackupJob(job, ids, notes).catch((err) => {
queueMicrotask(async () => {
void processBackupJob(job, ids, notes).catch(async (err) => {
job.status = "failed"
job.finishedAt = new Date().toISOString()
const failure = {
@@ -137,7 +137,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
error: err instanceof Error ? err.message : String(err),
}
job.failures.push(failure)
appendEvent({
await appendEvent({
level: "critical",
eventType: "backups.job.failed",
sourceModule: "backups",
@@ -167,7 +167,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const hit = getBackupById(req.params.id)
const hit = await getBackupById(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
const filePath = path.join(getBackupsDir(), hit.filename)
const content = await readFile(filePath, "utf8").catch(() => null)
+4 -4
View File
@@ -12,7 +12,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug)
app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = db.select().from(servers).where(eq(servers.id, params.id)).limit(1).all()[0]
const server = (await db.select().from(servers).where(eq(servers.id, params.id)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
@@ -26,7 +26,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/bgp/sessions — aggregate from ALL enabled servers
app.get("/bgp/sessions", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results: BgpSessionRead[][] = await Promise.all(
allServers.map(async (server) => {
@@ -46,10 +46,10 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/bgp/sessions — single server
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1).all()[0]
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
+10 -10
View File
@@ -32,7 +32,7 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.get("/certificates/acme-settings", async (_req, reply) => {
return reply.send(getAcmeSettingsPublic())
return reply.send(await getAcmeSettingsPublic())
})
app.put("/certificates/acme-settings", async (req, reply) => {
@@ -40,11 +40,11 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
return reply.send(updateAcmeSettings(parsed.data))
return reply.send(await updateAcmeSettings(parsed.data))
})
app.get("/certificates/renew-settings", async (_req, reply) => {
return reply.send(getCertificateRenewSettings())
return reply.send(await getCertificateRenewSettings())
})
app.put("/certificates/renew-settings", async (req, reply) => {
@@ -52,8 +52,8 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const result = updateCertificateRenewSettings(parsed.data)
refreshScheduler()
const result = await updateCertificateRenewSettings(parsed.data)
await refreshScheduler()
return reply.send(result)
})
@@ -62,7 +62,7 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const effective = parsed.data.cloudflareApiToken?.trim() || getAcmeCloudflareToken()
const effective = parsed.data.cloudflareApiToken?.trim() || await getAcmeCloudflareToken()
if (!effective) {
return reply.status(400).send({ error: "Не задан Cloudflare API token" })
}
@@ -83,12 +83,12 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const serverId = String(parsed.data.serverId)
const server = getServerRowByIdString(serverId)
const server = await getServerRowByIdString(serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const jobId = randomUUID()
const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",")
createIssueJobRecord({
await createIssueJobRecord({
id: jobId,
serverId,
certName: parsed.data.certName.trim(),
@@ -103,9 +103,9 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/certificates/issue/:jobId", async (req, reply) => {
const jobId = String((req.params as { jobId: string }).jobId)
const row = getIssueJobRecord(jobId)
const row = await getIssueJobRecord(jobId)
if (!row) return reply.status(404).send({ error: "Задача не найдена" })
return reply.send(toIssueJobDto(row))
return reply.send(await toIssueJobDto(row))
})
}
+2 -2
View File
@@ -8,7 +8,7 @@ const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректные параметры запроса", details: parsed.error.flatten() })
}
return reply.send({ events: readEvents(parsed.data) })
return reply.send({ events: await readEvents(parsed.data) })
})
app.post("/events/batch", async (req, reply) => {
@@ -16,7 +16,7 @@ const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
appendEvents(parsed.data.events)
await appendEvents(parsed.data.events)
return reply.status(201).send({ ok: true, inserted: parsed.data.events.length })
})
}
+17 -19
View File
@@ -17,11 +17,11 @@ const TestEvobgpSchema = z.object({
apiKey: z.string().optional(),
})
function ensureEvobgpRow() {
let row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
async function ensureEvobgpRow() {
let row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0]
if (!row) {
db.insert(evobgpSettings).values({ id: 1 }).run()
row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
await db.insert(evobgpSettings).values({ id: 1 })
row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0]
}
return row!
}
@@ -108,11 +108,10 @@ function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map<string, numb
* Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN).
* Ключ значение `filter_rules.community` после trim (как в EvoBGP `community`).
*/
function distinctServersByFilterCommunityValue(): Map<string, number> {
const rows = db
async function distinctServersByFilterCommunityValue(): Promise<Map<string, number>> {
const rows = await db
.select({ serverId: filterRules.serverId, community: filterRules.community })
.from(filterRules)
.all()
const byVal = new Map<string, Set<number>>()
for (const r of rows) {
const v = (r.community ?? "").trim()
@@ -161,8 +160,8 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
}
}
function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow()
async function credentialsFromDb(): Promise<{ root: string; apiKey: string } | null> {
const row = await ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl)
const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null
@@ -171,7 +170,7 @@ function credentialsFromDb(): { root: string; apiKey: string } | null {
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/evobgp/settings", async (_req, reply) => {
const row = ensureEvobgpRow()
const row = await ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: Boolean(row.enabled),
@@ -184,7 +183,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const cur = ensureEvobgpRow()
const cur = await ensureEvobgpRow()
let nextBase = cur.baseUrl
let nextEnabled = cur.enabled
let nextKey = cur.apiKey
@@ -200,17 +199,16 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
: normalizeApiKey(parsed.data.apiKey)
}
db.update(evobgpSettings)
await db.update(evobgpSettings)
.set({
baseUrl: nextBase,
enabled: nextEnabled,
apiKey: nextKey,
updatedAt: sql`(datetime('now'))`,
updatedAt: sql`now()`,
})
.where(eq(evobgpSettings.id, 1))
.run()
const row = ensureEvobgpRow()
const row = await ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: Boolean(row.enabled),
@@ -228,7 +226,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const row = ensureEvobgpRow()
const row = await ensureEvobgpRow()
const d = parsed.data
const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl
const keyRaw =
@@ -253,11 +251,11 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
* Прокси к GET /v1/router-lists/catalog учётные данные только из БД.
*/
app.post("/evobgp/catalog", async (_req, reply) => {
const row = ensureEvobgpRow()
const row = await ensureEvobgpRow()
if (!row.enabled) {
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
}
const cred = credentialsFromDb()
const cred = await credentialsFromDb()
if (!cred) {
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
}
@@ -333,7 +331,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
})
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
const serversByCommunityStr = distinctServersByFilterCommunityValue()
const serversByCommunityStr = await distinctServersByFilterCommunityValue()
const communities = commItems.map((c) => {
const valueStr = (c.community ?? "").trim()
+2 -2
View File
@@ -314,10 +314,10 @@ const execRoutes: FastifyPluginAsyncZod = async (app) => {
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1).all()[0]
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
+35 -39
View File
@@ -186,12 +186,11 @@ function gatewayFromRecursiveDst(dstAddress: string): string {
* Импорт с роутера даёт только `set gateway` без `set out-interface` в таком виде не отличить от «поломанного» GRE.
* Сопоставляем hop с локальной таблицей recursive_routes и восстанавливаем gatewayTunnelId = rec:<id>.
*/
function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): ApiFilterRule[] {
const rows = db
async function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): Promise<ApiFilterRule[]> {
const rows = await db
.select()
.from(recursiveRoutes)
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.disabled, false)))
.all()
return rules.map(rule => {
if (rule.action === "blackhole") return rule
@@ -249,7 +248,7 @@ async function fetchServerFilters(server: ServerRow) {
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
.flatMap(parseFilterRule)
const rules = enrichRulesWithRecursiveGateway(server.id, rulesRaw)
const rules = await enrichRulesWithRecursiveGateway(server.id, rulesRaw)
return {
serverId: String(server.id),
@@ -258,8 +257,8 @@ async function fetchServerFilters(server: ServerRow) {
}
}
function toApiRulesets(serverRows: ServerRow[]) {
const dbRules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all()
async function toApiRulesets(serverRows: ServerRow[]) {
const dbRules = await db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder))
return serverRows.map(s => ({
serverId: String(s.id),
rules: dbRules
@@ -277,7 +276,7 @@ function toApiRulesets(serverRows: ServerRow[]) {
}
/** GRE: gatewayTunnelId = имя интерфейса; рекурсивный: rec:<id строки recursive_routes */
function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway: string; outIface: string } {
async function resolveRouteTargets(serverId: number, rule: ApiFilterRule): Promise<{ gateway: string; outIface: string }> {
if (rule.action === "blackhole") return { gateway: "", outIface: "" }
const tid = (rule.gatewayTunnelId ?? "").trim()
if (tid.startsWith("rec:")) {
@@ -285,12 +284,11 @@ function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway:
if (!Number.isFinite(rid)) {
return { gateway: rule.gateway, outIface: "" }
}
const row = db
const row = (await db
.select()
.from(recursiveRoutes)
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.id, rid)))
.limit(1)
.all()[0]
.limit(1))[0]
if (!row) return { gateway: rule.gateway, outIface: "" }
const gw = gatewayFromRecursiveDst(row.dstAddress)
return { gateway: gw || rule.gateway, outIface: "" }
@@ -303,30 +301,30 @@ function normalizeCommunity(c: string): string {
}
/** Одинаковый эффект на роутере при одинаковой community (blackhole vs gateway + out-interface) */
function ruleEffectSignature(serverId: number, r: ApiFilterRule): string {
async function ruleEffectSignature(serverId: number, r: ApiFilterRule): Promise<string> {
if (r.action === "blackhole") return `bh:${normalizeCommunity(r.community)}`
const { gateway, outIface } = resolveRouteTargets(serverId, r)
const { gateway, outIface } = await resolveRouteTargets(serverId, r)
return `rt:${normalizeCommunity(r.community)}:${gateway}:${outIface}`
}
export type FilterRouterCompareStatus = "synced" | "drift" | "missing"
function compareDbRulesWithRouter(
async function compareDbRulesWithRouter(
serverId: number,
dbRules: ApiFilterRule[],
remoteRules: ApiFilterRule[],
): Record<string, FilterRouterCompareStatus> {
): Promise<Record<string, FilterRouterCompareStatus>> {
const remoteSigByComm = new Map<string, string>()
for (const rr of remoteRules) {
const c = normalizeCommunity(rr.community)
if (!remoteSigByComm.has(c)) {
remoteSigByComm.set(c, ruleEffectSignature(serverId, rr))
remoteSigByComm.set(c, await ruleEffectSignature(serverId, rr))
}
}
const out: Record<string, FilterRouterCompareStatus> = {}
for (const dr of dbRules) {
const c = normalizeCommunity(dr.community)
const sigD = ruleEffectSignature(serverId, dr)
const sigD = await ruleEffectSignature(serverId, dr)
const sigR = remoteSigByComm.get(c)
if (sigR === undefined) {
out[c] = "missing"
@@ -339,7 +337,7 @@ function compareDbRulesWithRouter(
return out
}
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
async function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): Promise<string> {
if (rules.length === 0) return ""
// Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же
// `set gw` объединяются через `||` в один if-блок — компактнее и ближе к привычному
@@ -361,7 +359,7 @@ function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
const isBlackhole = rule.action === "blackhole"
const { gateway, outIface } = isBlackhole
? { gateway: "", outIface: "" }
: resolveRouteTargets(serverId, rule)
: await resolveRouteTargets(serverId, rule)
const key = isBlackhole ? "bh" : `rt:${gateway}:${outIface}`
let idx = indexByKey.get(key)
if (idx === undefined) {
@@ -404,10 +402,10 @@ function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
}
async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
db.delete(filterRules).where(eq(filterRules.serverId, serverId)).run()
await db.delete(filterRules).where(eq(filterRules.serverId, serverId))
if (rules.length === 0) return
const now = new Date().toISOString()
db.insert(filterRules).values(
await db.insert(filterRules).values(
rules.map((r, i) => ({
serverId,
sortOrder: i,
@@ -420,7 +418,7 @@ async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
createdAt: now,
updatedAt: now,
})),
).run()
)
}
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
@@ -432,17 +430,16 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "serverId is required" })
}
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const remote = await fetchServerFilters(server)
const rows = db
const rows = await db
.select()
.from(filterRules)
.where(eq(filterRules.serverId, serverId))
.orderBy(asc(filterRules.sortOrder))
.all()
const dbRules: ApiFilterRule[] = rows.map(r => ({
id: String(r.id),
@@ -454,7 +451,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
description: r.description,
}))
const byCommunity = compareDbRulesWithRouter(serverId, dbRules, remote.rules)
const byCommunity = await compareDbRulesWithRouter(serverId, dbRules, remote.rules)
return reply.send({ byCommunity })
} catch (err) {
app.log.error({ serverId, err: String(err) }, "filters router-compare failed")
@@ -466,7 +463,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/filters/gre-tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const sid = parseDbServerId(q.serverId)
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
if (sid !== null) {
const server = enabledServers.find(s => s.id === sid)
@@ -492,8 +489,8 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
/** Только правила фильтров из БД (без опроса MikroTik за GRE) */
app.get("/filters/rules", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const dbRulesets = toApiRulesets(allServers)
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const dbRulesets = await toApiRulesets(allServers)
return reply.send({
rulesets: dbRulesets,
@@ -506,7 +503,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
const payload = body.rulesets ?? []
const serverIds = payload.map(r => Number.parseInt(r.serverId, 10)).filter(Number.isFinite)
if (serverIds.length > 0) {
db.delete(filterRules).where(inArray(filterRules.serverId, serverIds)).run()
await db.delete(filterRules).where(inArray(filterRules.serverId, serverIds))
}
for (const rs of payload) {
const sid = Number.parseInt(rs.serverId, 10)
@@ -524,12 +521,12 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "serverId is required" })
}
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
appendEvent({
await appendEvent({
level: "info",
eventType: "filters.sync.from_router.started",
sourceModule: "filters",
@@ -541,7 +538,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
const remote = await fetchServerFilters(server)
await replaceDbRules(server.id, remote.rules)
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed")
appendEvent({
await appendEvent({
level: "info",
eventType: "filters.sync.from_router.done",
sourceModule: "filters",
@@ -553,7 +550,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId })
} catch (err) {
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
appendEvent({
await appendEvent({
level: "critical",
eventType: "filters.sync.from_router.failed",
sourceModule: "filters",
@@ -570,7 +567,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
const body = req.body as { serverId?: string | number } | undefined
const requestedServerId = parseDbServerId(body?.serverId)
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const targetServers = requestedServerId !== null
? allServers.filter(s => s.id === requestedServerId)
: allServers
@@ -582,7 +579,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
let updatedServers = 0
let pushedRules = 0
const errors: Array<{ serverId: number; error: string }> = []
appendEvent({
await appendEvent({
level: "info",
eventType: "filters.sync.to_router.started",
sourceModule: "filters",
@@ -618,10 +615,9 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
.map(r => r[".id"])
.filter((id): id is string => Boolean(id))
const rows = db.select().from(filterRules)
const rows = await db.select().from(filterRules)
.where(and(eq(filterRules.serverId, server.id)))
.orderBy(asc(filterRules.sortOrder))
.all()
const rules: ApiFilterRule[] = rows.map(r => ({
id: String(r.id),
@@ -639,7 +635,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
// трактует как «вызов команды» и отдаёт 400 «no such command».
// См. https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
if (rules.length > 0) {
const ruleBody = toRouterRuleBody(server.id, rules)
const ruleBody = await toRouterRuleBody(server.id, rules)
if (managedRule && managedRule[".id"]) {
await client.patch(
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
@@ -688,7 +684,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
}
}
appendEvent({
await appendEvent({
level: errors.length === 0 ? "info" : "warning",
eventType: errors.length === 0 ? "filters.sync.to_router.done" : "filters.sync.to_router.partial",
sourceModule: "filters",
+294
View File
@@ -0,0 +1,294 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js"
import { listFirewallAll } from "../services/firewall-live.js"
import type { FirewallFamily, FirewallTable } from "../types/server.js"
const FamilySchema = z.enum(["ip", "ip6"])
const TableSchema = z.enum(["filter", "nat", "mangle", "raw"])
const RuleKeySchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
table: TableSchema,
rosId: z.string().min(1),
})
const RuleWriteSchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
table: TableSchema,
rosId: z.string().min(1).optional(),
chain: z.string().min(1),
action: z.string().min(1),
protocol: z.string().optional(),
srcAddress: z.string().optional(),
dstAddress: z.string().optional(),
srcAddressList: z.string().optional(),
dstAddressList: z.string().optional(),
srcPort: z.string().optional(),
dstPort: z.string().optional(),
inInterface: z.string().optional(),
outInterface: z.string().optional(),
connectionState: z.string().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
log: z.boolean().optional(),
logPrefix: z.string().optional(),
tlsHost: z.string().optional(),
layer7Proto: z.string().optional(),
})
const RulePatchSchema = RuleKeySchema.extend({
disabled: z.boolean(),
})
const RuleMoveSchema = RuleKeySchema.extend({
destinationRosId: z.string().min(1).optional(),
})
const AddressKeySchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
rosId: z.string().min(1),
})
const AddressWriteSchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
rosId: z.string().min(1).optional(),
list: z.string().min(1),
address: z.string().min(1),
comment: z.string().optional(),
timeout: z.string().optional(),
disabled: z.boolean().optional(),
})
const AddressPatchSchema = AddressKeySchema.extend({
disabled: z.boolean(),
})
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== "") out[k] = v
}
return out
}
function ruleToRos(d: z.infer<typeof RuleWriteSchema>): Record<string, string> {
return toRosBody({
chain: d.chain,
action: d.action,
protocol: d.protocol && d.protocol !== "all" ? d.protocol : undefined,
"src-address": d.srcAddress,
"dst-address": d.dstAddress,
"src-address-list": d.srcAddressList,
"dst-address-list": d.dstAddressList,
"src-port": d.srcPort,
"dst-port": d.dstPort,
"in-interface": d.inInterface,
"out-interface": d.outInterface,
"connection-state": d.connectionState,
comment: d.comment,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
log: d.log === true ? "yes" : d.log === false ? "no" : undefined,
"log-prefix": d.logPrefix,
"tls-host": d.tlsHost,
"layer7-protocol": d.layer7Proto,
})
}
function addressToRos(d: z.infer<typeof AddressWriteSchema>): Record<string, string> {
return toRosBody({
list: d.list,
address: d.address,
comment: d.comment,
timeout: d.timeout,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
})
}
function rosErr(e: unknown): string {
if (e instanceof MikrotikError) return e.message
if (e instanceof Error) return e.message
return String(e)
}
async function requireServer(serverId: string) {
return await getEnabledServerById(serverId)
}
const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/firewall/all", async (_req, reply) => {
const data = await listFirewallAll()
return reply.send(data)
})
app.post("/firewall/rules", async (req, reply) => {
const parsed = RuleWriteSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
try {
await client.put(path, ruleToRos(body))
return reply.status(201).send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.put("/firewall/rules", async (req, reply) => {
const parsed = RuleWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, ruleToRos(body))
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.patch("/firewall/rules", async (req, reply) => {
const parsed = RulePatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.delete("/firewall/rules", async (req, reply) => {
const parsed = RuleKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try {
await client.delete(path)
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.post("/firewall/rules/move", async (req, reply) => {
const parsed = RuleMoveSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/move`
try {
await client.post(path, {
numbers: body.rosId,
...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
})
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.post("/firewall/address-lists", async (req, reply) => {
const parsed = AddressWriteSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
return reply.status(201).send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.put("/firewall/address-lists", async (req, reply) => {
const parsed = AddressWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, addressToRos(body))
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.patch("/firewall/address-lists", async (req, reply) => {
const parsed = AddressPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.delete("/firewall/address-lists", async (req, reply) => {
const parsed = AddressKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try {
await client.delete(path)
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
}
export default firewallRoutes
+11 -9
View File
@@ -8,7 +8,7 @@ import {
const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/internet-path/settings", async (_req, reply) => {
const s = getInternetPathSettings()
const s = await getInternetPathSettings()
return reply.send({
enabled: s.enabled,
intervalSec: s.intervalSec,
@@ -25,12 +25,12 @@ const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
intervalSec?: number | string
retentionDays?: number | string
}
const updated = updateInternetPathSettings({
const updated = await updateInternetPathSettings({
enabled: body.enabled,
intervalSec: body.intervalSec == null ? undefined : Math.max(30, Number.parseInt(String(body.intervalSec), 10) || 300),
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
})
refreshScheduler()
await refreshScheduler()
return reply.send({
ok: true,
settings: {
@@ -45,13 +45,15 @@ const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.get("/internet-path/latest", async (_req, reply) => {
const row = getLatestInternetPathSnapshot()
const row = await getLatestInternetPathSnapshot()
if (!row) return reply.send({ snapshot: null })
let payload: unknown = null
try {
payload = JSON.parse(row.payloadJson)
} catch {
payload = null
let payload: unknown = row.payloadJson
if (typeof row.payloadJson === "string") {
try {
payload = JSON.parse(row.payloadJson)
} catch {
payload = null
}
}
return reply.send({
snapshot: payload,
+23 -28
View File
@@ -207,23 +207,21 @@ function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeig
return Math.round(w * pingScore + (1 - w) * speedScore)
}
function getLatestSnapshotLatencyMs(serverId: number): number {
const latest = db
async function getLatestSnapshotLatencyMs(serverId: number): Promise<number> {
const latest = await db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt))
.limit(1)
.all()
return latest.length > 0 ? Math.max(1, Math.round(latest[0].latencyMs ?? 100)) : 100
}
function latestTrafficByInterface(serverId: number): Map<string, TrafficSampleRow> {
const rows = db
async function latestTrafficByInterface(serverId: number): Promise<Map<string, { id: number; serverId: number; disabled: boolean; sampledAt: string; interfaceName: string; peerPublicKey: string; rxBytes: number; txBytes: number; rxBps: number; txBps: number; running: boolean; }>> {
const rows = await db
.select()
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.all()
const map = new Map<string, TrafficSampleRow>()
for (const row of rows) {
const prev = map.get(row.interfaceName)
@@ -302,14 +300,14 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
const areaMap = buildAreaMap(areas)
const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface))
const localLatencyMs = getLatestSnapshotLatencyMs(server.id)
const latestTraffic = latestTrafficByInterface(server.id)
const localLatencyMs = await getLatestSnapshotLatencyMs(server.id)
const latestTraffic = await latestTrafficByInterface(server.id)
// 1) Сопоставляем remote OSPF router-id -> сервер из каталога (чтобы взять унифицированный ping как в карте /route-optimizer).
const neededRouterIds = new Set(neighbors.map((n) => String(n["router-id"] ?? "")).filter((x) => x.length > 0))
async function buildRouterIdToServerMap(needed: Set<string>): Promise<Map<string, ServerRow["id"]>> {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
const out = new Map<string, ServerRow["id"]>()
const neededLeft = new Set(needed)
@@ -356,11 +354,10 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
}
// 3) Предгружаем uptimeSpeedProbes: оттуда берём и ping, и скорость (единый источник как у карты).
const allSourceProbes = db
const allSourceProbes = (await db
.select()
.from(uptimeSpeedProbes)
.where(eq(uptimeSpeedProbes.srcServerId, server.id))
.all()
.where(eq(uptimeSpeedProbes.srcServerId, server.id)))
.filter((r: SpeedProbeRow) => r.enabled !== false)
const speedProbesByDest = new Map<number, SpeedProbeRow[]>()
for (const p of allSourceProbes) {
@@ -482,8 +479,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
for (const [iface, probe] of assigned.entries()) assignedProbeByIface.set(iface, probe)
}
return editable
.map((iface) => {
return (await Promise.all(editable.map(async (iface) => {
const t = latestTraffic.get(iface.interface)
const speed = normalizeSpeedMbps(t)
@@ -494,7 +490,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
bestProbe?.lastPingRttMs != null
? Math.max(1, Math.round(bestProbe.lastPingRttMs))
: dstServerId != null
? Math.min(995, Math.round(localLatencyMs + getLatestSnapshotLatencyMs(dstServerId)))
? Math.min(995, Math.round(localLatencyMs + await getLatestSnapshotLatencyMs(dstServerId)))
: localLatencyMs
const dlMbps =
@@ -517,8 +513,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
score: calcRouteScore(pingMs, dlMbps, ulMbps, pingWeight),
optimalCost: 0,
}
})
.sort((a, b) => b.score - a.score || a.interface.localeCompare(b.interface))
}))).sort((a, b) => b.score - a.score || a.interface.localeCompare(b.interface))
.map((row, idx) => ({ ...row, optimalCost: (idx + 1) * 10 }))
}
@@ -528,7 +523,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers
app.get("/ospf/neighbors", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
@@ -547,7 +542,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/interfaces — aggregate OSPF interface templates from ALL enabled servers
app.get("/ospf/interfaces", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
@@ -566,7 +561,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/instances — aggregate OSPF instances from ALL enabled servers
app.get("/ospf/instances", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
@@ -584,7 +579,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/all — single round-trip: neighbors + interfaces + instances + BFD
app.get("/ospf/all", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const perServer = await Promise.all(
allServers.map(async (server) => {
@@ -613,7 +608,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/bfd/sessions — BFD sessions only (for direct access)
app.get("/bfd/sessions", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
@@ -631,10 +626,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/ospf — single server OSPF + BFD data
app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1).all()[0]
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
@@ -659,10 +654,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1).all()[0]
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
@@ -691,10 +686,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = db
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1).all()[0]
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
+6 -6
View File
@@ -256,14 +256,14 @@ async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: str
return lines.join("\n")
}
function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
async function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
if (explicitDstId != null && Number.isFinite(explicitDstId)) {
const s = db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1).all()[0]
const s = (await db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1))[0]
if (s) return s
}
const norm = remoteHost.trim().toLowerCase()
return db.select().from(servers).where(eq(servers.enabled, true)).all()
.find((x) => x.host.trim().toLowerCase() === norm)
const enabled = await db.select().from(servers).where(eq(servers.enabled, true))
return enabled.find((x) => x.host.trim().toLowerCase() === norm)
}
const probesRoutes: FastifyPluginAsyncZod = async (app) => {
@@ -271,7 +271,7 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => {
const sid = parseServerId(String((req.params as { id?: string }).id ?? ""))
if (sid === null) return reply.status(400).send({ error: "Invalid server id" })
const server = db.select().from(servers).where(eq(servers.id, sid)).limit(1).all()[0]
const server = (await db.select().from(servers).where(eq(servers.id, sid)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const body = req.body as {
@@ -369,7 +369,7 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => {
case "bandwidth": {
const remote = String(body.bwRemoteAddress ?? "").trim()
if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" })
const dst = resolveBtestPeer(remote, body.dstServerId)
const dst = await resolveBtestPeer(remote, body.dstServerId)
if (!dst) {
return reply.status(400).send({
error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId",
+13 -14
View File
@@ -80,13 +80,12 @@ function splitGateway(raw: string): { ip: string; name: string } | null {
return { ip, name: name || ip }
}
function mapDbRoutes(serverId: number): RecursiveRouteDto[] {
const rows = db
async function mapDbRoutes(serverId: number): Promise<RecursiveRouteDto[]> {
const rows = await db
.select()
.from(recursiveRoutes)
.where(eq(recursiveRoutes.serverId, serverId))
.orderBy(asc(recursiveRoutes.sortOrder))
.all()
return rows.map((r) => ({
id: String(r.id),
@@ -118,10 +117,10 @@ function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
}
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId)).run()
await db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
if (routes.length === 0) return
const now = new Date().toISOString()
db.insert(recursiveRoutes).values(
await db.insert(recursiveRoutes).values(
routes.map((r, i) => ({
serverId,
sortOrder: i,
@@ -138,7 +137,7 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
createdAt: now,
updatedAt: now,
})),
).run()
)
}
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
@@ -146,7 +145,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
@@ -176,16 +175,16 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send({ routes: mapDbRoutes(serverId) })
return reply.send({ routes: await mapDbRoutes(serverId) })
})
app.put("/recursive-routes", async (req, reply) => {
const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] }
const serverId = parseDbServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
await replaceDbRoutes(serverId, body.routes ?? [])
return reply.send({ ok: true })
@@ -195,10 +194,10 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseDbServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server: ServerRow | undefined = db
const server: ServerRow | undefined = (await db
.select().from(servers)
.where(eq(servers.id, serverId))
.limit(1).all()[0]
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
@@ -231,7 +230,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseDbServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
@@ -243,7 +242,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
}
const dbRows = mapDbRoutes(serverId)
const dbRows = await mapDbRoutes(serverId)
for (const route of dbRows) {
await client.post("/ip/route", toRouterPayload(route))
}
+5 -5
View File
@@ -14,7 +14,7 @@ function parseJobKey(raw: string | undefined): typeof JOB_KEYS[number] | null {
const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/scheduler/status", async (_req, reply) => {
return reply.send(getSchedulerStatus())
return reply.send(await getSchedulerStatus())
})
app.get("/scheduler/runs", async (req, reply) => {
@@ -25,12 +25,12 @@ const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
}
const limit = Math.min(200, Math.max(1, Number.parseInt(String(q.limit ?? "50"), 10) || 50))
const offset = Math.max(0, Number.parseInt(String(q.offset ?? "0"), 10) || 0)
return reply.send(listSchedulerRuns({ jobKey: jobKey ?? undefined, limit, offset }))
return reply.send(await listSchedulerRuns({ jobKey: jobKey ?? undefined, limit, offset }))
})
app.post("/scheduler/refresh", async (_req, reply) => {
refreshScheduler()
return reply.send({ ok: true, status: getSchedulerStatus() })
await refreshScheduler()
return reply.send({ ok: true, status: await getSchedulerStatus() })
})
app.post("/scheduler/jobs/:jobKey/run-now", async (req, reply) => {
@@ -38,7 +38,7 @@ const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
if (!jobKey) return reply.status(400).send({ error: "Некорректный jobKey" })
try {
await runSchedulerJobNow(jobKey)
return reply.send({ ok: true, status: getSchedulerStatus() })
return reply.send({ ok: true, status: await getSchedulerStatus() })
} catch (e) {
const statusCode = (e as Error & { statusCode?: number }).statusCode
if (statusCode === 409) {
+4 -4
View File
@@ -10,7 +10,7 @@ import {
const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/servers-api-ping/settings", async (_req, reply) => {
const settings = getServersApiPingSettings()
const settings = await getServersApiPingSettings()
const state = getServersRestPingCollectorState()
return reply.send({
enabled: settings.enabled,
@@ -29,11 +29,11 @@ const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
}
const intervalSec =
body.intervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.intervalSec), 10) || 120)
const updated = updateServersApiPingSettings({
const updated = await updateServersApiPingSettings({
enabled: body.enabled,
intervalSec,
})
refreshScheduler()
await refreshScheduler()
return reply.send({
ok: true,
settings: {
@@ -49,7 +49,7 @@ const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/servers-api-ping/collect-now", async (_req, reply) => {
await collectServersRestPingOnce()
scheduleAlertEngineAfterDataCollectors()
const row = getServersApiPingSettings()
const row = await getServersApiPingSettings()
return reply.send({
ok: true,
lastCollectedAt: row.lastCollectedAt ?? null,
+15 -13
View File
@@ -77,13 +77,13 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers
app.get("/", async (_req, reply) => {
return reply.send(listServersRead())
return reply.send(await listServersRead())
})
// GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN)
app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = getServerRowById(params.id)
const server = await getServerRowById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
const ipv4 = await resolveRosSrcIpv4(server.host)
@@ -93,7 +93,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/servers/:id/wan-runtime — DHCP lease + active default route for HomeRouter WAN uplinks
app.get("/:id/wan-runtime", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = getServerReadById(params.id)
const server = await getServerReadById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
const toIp = (raw: string | null | undefined): string | null => {
@@ -141,7 +141,9 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
}
try {
const client = MikrotikClient.fromServer(getServerRowById(params.id)!)
const row = await getServerRowById(params.id)
if (!row) return reply.status(404).send({ error: "Server not found" })
const client = MikrotikClient.fromServer(row)
const isTrue = (v: unknown) => {
const s = String(v ?? "").trim().toLowerCase()
return s === "true" || s === "yes"
@@ -228,13 +230,13 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
return reply.status(201).send(createServer(req.body as ServerCreateRequest))
return reply.status(201).send(await createServer(req.body as ServerCreateRequest))
})
// GET /api/servers/:id
app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = getServerReadById(params.id)
const server = await getServerReadById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send(server)
})
@@ -245,25 +247,25 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
{ schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const existing = getServerReadById(params.id)
const existing = await getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" })
return reply.send(updateServer(params.id, req.body as ServerUpdateRequest))
return reply.send(await updateServer(params.id, req.body as ServerUpdateRequest))
},
)
// DELETE /api/servers/:id
app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const existing = getServerReadById(params.id)
const existing = await getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" })
deleteServer(params.id)
await deleteServer(params.id)
return reply.status(204).send()
})
// POST /api/servers/:id/poll
app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const existing = getServerReadById(params.id)
const existing = await getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" })
try {
@@ -281,9 +283,9 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
async (req, reply) => {
const params = req.params as ServerIdParams
const query = req.query as SnapshotsQuery
const existing = getServerReadById(params.id)
const existing = await getServerReadById(params.id)
if (!existing) return reply.status(404).send({ error: "Server not found" })
return reply.send(listServerSnapshots(params.id, query.limit))
return reply.send(await listServerSnapshots(params.id, query.limit))
},
)
}
+17 -15
View File
@@ -1,4 +1,5 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { count } from "drizzle-orm"
import { listCertificatesFromServers } from "../services/certificates-service.js"
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
import { db } from "../db/index.js"
@@ -9,26 +10,26 @@ import {
uptimeProbes,
uptimeSpeedProbes,
} from "../db/schema.js"
import { listUsers } from "../modules/users/service/users-service.js"
async function tableCount(table: typeof servers | typeof filterRules | typeof uptimeProbes | typeof uptimeSpeedProbes | typeof recursiveRoutes): Promise<number> {
const rows = await db.select({ n: count() }).from(table)
return rows[0]?.n ?? 0
}
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/sidebar-counts", async (_req, reply) => {
const [
serversTotal,
filterRulesTotal,
uptimeProbesTotal,
uptimeSpeedProbesTotal,
recursiveRoutesTotal,
certificatesTotal,
wireguardTotal,
] = await Promise.all([
Promise.resolve(db.select().from(servers).all().length),
Promise.resolve(db.select().from(filterRules).all().length),
Promise.resolve(db.select().from(uptimeProbes).all().length),
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
Promise.resolve(db.select().from(recursiveRoutes).all().length),
listCertificatesFromServers().then((res) => res.certificates.length),
const serversTotal = await tableCount(servers)
const filterRulesTotal = await tableCount(filterRules)
const uptimeProbesTotal = await tableCount(uptimeProbes)
const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes)
const recursiveRoutesTotal = await tableCount(recursiveRoutes)
const [certRes, wireguardTotal] = await Promise.all([
listCertificatesFromServers(),
countWireGuardInterfaces().catch(() => 0),
])
const certificatesTotal = certRes.certificates.length
const usersTotal = (await listUsers()).length
return reply.send({
servers: serversTotal,
@@ -39,6 +40,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal,
wireguard: wireguardTotal,
users: usersTotal,
})
})
}
+4 -4
View File
@@ -18,7 +18,7 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
app.get("/system/database/backup", async (_req, reply) => {
try {
const { filename, buffer } = await exportSystemDatabaseBackup()
appendEvent({
await appendEvent({
level: "info",
eventType: "system.database.backup",
sourceModule: "system",
@@ -44,16 +44,16 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
app.post("/system/database/restore", async (req, reply) => {
const body = req.body
if (!Buffer.isBuffer(body) || body.length === 0) {
return reply.status(400).send({ error: "Ожидается тело запроса с файлом SQLite" })
return reply.status(400).send({ error: "Ожидается тело запроса с файлом pg_dump (custom, PGDMP)" })
}
try {
await restoreSystemDatabaseBackup(body)
appendEvent({
await appendEvent({
level: "warning",
eventType: "system.database.restore",
sourceModule: "system",
title: "Восстановлена база приложения",
message: "Данные SQLite заменены из загруженного файла",
message: "Данные PostgreSQL заменены из загруженного дампа",
entityType: "system_database",
entityId: "restore",
payload: {
+296
View File
@@ -0,0 +1,296 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { FastifyReply, FastifyRequest } from "fastify"
import { env } from "../config.js"
import {
trafficFlowOverlayRequestSchema,
trafficFlowSettingsPatchSchema,
} from "@mmapp/contracts/traffic-flow"
import {
ensureHostKeys,
getTrafficFlowSettingsRow,
toTrafficFlowSettingsDto,
updateTrafficFlowSettings,
} from "../services/traffic-flow-settings.js"
import {
getFlowListenerState,
purgeTrafficFlowStore,
startTrafficFlowListener,
listFlowTalkers,
} from "../services/traffic-flow-ingest.js"
import {
buildFlowAnalytics,
getFlowMonthly,
listFlowClients,
listFlowExporters,
safeBuildLiveFlowSample,
} from "../services/traffic-flow-analytics.js"
import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js"
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
import { appendEvent } from "../modules/events/service/events-service.js"
const LIVE_TICK_MS = 2000
export const MAX_FLOW_LIVE_SUBSCRIBERS = 4
let liveSubscribers = 0
export function tryAcquireFlowLiveSlot(): boolean {
if (liveSubscribers >= MAX_FLOW_LIVE_SUBSCRIBERS) return false
liveSubscribers += 1
return true
}
export function releaseFlowLiveSlot(): void {
liveSubscribers = Math.max(0, liveSubscribers - 1)
}
export function resetFlowLiveSlotsForTests(): void {
liveSubscribers = 0
}
function rangeToMinutes(range: string | undefined): number {
switch ((range ?? "5m").toLowerCase()) {
case "5m": return 5
case "15m": return 15
case "1h": return 60
case "4h": return 240
case "24h": return 1440
case "30d": return 1440
default: return 5
}
}
function parseId(raw: unknown): number | undefined {
if (raw == null || raw === "") return undefined
const n = Number.parseInt(String(raw), 10)
return Number.isFinite(n) ? n : undefined
}
function parseDedup(raw: unknown): boolean {
if (raw == null || raw === "") return true
const s = String(raw).toLowerCase()
return s !== "0" && s !== "false" && s !== "off"
}
function analyticsQuery(req: FastifyRequest) {
const q = req.query as {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: string
excludeMesh?: string
excludeOverlay?: string
}
return {
minutes: rangeToMinutes(q.range),
serverId: parseId(q.serverId),
userId: q.userId?.trim() || undefined,
iface: q.iface?.trim() || undefined,
dedup: parseDedup(q.dedup),
excludeMesh: parseDedup(q.excludeMesh),
excludeOverlay: parseDedup(q.excludeOverlay),
}
}
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
const q = req.query as { range?: string }
return reply.send(await listFlowTalkers(rangeToMinutes(q.range)))
}
function requestPublicHost(req: FastifyRequest): string {
const forwarded = req.headers["x-forwarded-host"]
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
return raw || req.hostname || ""
}
async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
try {
const result = await applyFlowOverlay(parsed.data.serverId, {
publicEndpoint: parsed.data.publicEndpoint,
requestHost: requestPublicHost(req),
})
return reply.send(result)
} catch (e) {
const status = (e as { statusCode?: number }).statusCode ?? 502
const msg = e instanceof Error ? e.message : String(e)
return reply.status(status).send({ error: msg })
}
}
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error("aborted"))
return
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
reject(new Error("aborted"))
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/flow/settings", async (_req, reply) => {
return reply.send(await toTrafficFlowSettingsDto(getFlowListenerState()))
})
app.put("/traffic/flow/settings", async (req, reply) => {
const parsed = trafficFlowSettingsPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
await updateTrafficFlowSettings(parsed.data)
await startTrafficFlowListener()
return reply.send({ ok: true, settings: await toTrafficFlowSettingsDto(getFlowListenerState()) })
})
app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => {
const result = await ensureHostKeys()
return reply.send({
ok: true,
created: result.created,
publicKey: result.publicKey,
settings: await toTrafficFlowSettingsDto(getFlowListenerState()),
})
})
app.get("/traffic/flow/host-files", async (_req, reply) => {
const row = await getTrafficFlowSettingsRow()
if (!row.hostPrivateKey) await ensureHostKeys()
return reply.send({ files: await listTrafficFlowHostFiles() })
})
app.post("/traffic/flow/purge", async (_req, reply) => {
try {
const result = await purgeTrafficFlowStore()
await appendEvent({
level: "warning",
eventType: "traffic.flow.purge",
sourceModule: "traffic",
title: "Сброшены данные NetFlow",
message: `Удалены сессии ${result.deleted.buckets}, minute ${result.deleted.minuteStats}, daily ${result.deleted.dailyDims}`,
entityType: "traffic_flow",
entityId: "purge",
payload: {
buckets: result.deleted.buckets,
minuteStats: result.deleted.minuteStats,
minuteDims: result.deleted.minuteDims,
dailyDims: result.deleted.dailyDims,
fileBytesBefore: result.fileBytesBefore,
fileBytesAfter: result.fileBytesAfter,
vacuumed: result.vacuumed,
},
})
return reply.send(result)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const status = message.includes("уже выполняется") ? 409 : 500
return reply.status(status).send({ error: message })
}
})
app.post("/traffic/flow/overlay", applyOverlayHandler)
app.post("/traffic/flow-overlay", applyOverlayHandler)
app.get("/traffic/flow", sendFlowTalkers)
app.get("/traffic/flows", sendFlowTalkers)
app.get("/traffic/flow/exporters", async (req, reply) => {
const q = req.query as { range?: string }
return reply.send(await listFlowExporters(rangeToMinutes(q.range)))
})
app.get("/traffic/flow/clients", async (req, reply) => {
const q = req.query as { range?: string }
return reply.send(await listFlowClients(rangeToMinutes(q.range)))
})
app.get("/traffic/flow/analytics", async (req, reply) => {
return reply.send(await buildFlowAnalytics(analyticsQuery(req)))
})
app.get("/traffic/flow/map-hops", async (req, reply) => {
return reply.send(await buildFlowMapHops(analyticsQuery(req)))
})
app.get("/traffic/flow/monthly", async (req, reply) => {
const q = req.query as { month?: string; serverId?: string }
const now = new Date()
const month = /^\d{4}-\d{2}$/.test(q.month ?? "")
? (q.month as string)
: `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`
return reply.send(await getFlowMonthly(month, parseId(q.serverId)))
})
app.get("/traffic/flow/live", async (req, reply) => {
if (!tryAcquireFlowLiveSlot()) {
return reply.status(429).send({ error: "Слишком много live-подписок" })
}
const query = analyticsQuery(req)
const liveQuery = {
serverId: query.serverId,
userId: query.userId,
iface: query.iface,
dedup: query.dedup,
excludeMesh: query.excludeMesh,
excludeOverlay: query.excludeOverlay,
}
const abort = new AbortController()
const onClose = () => abort.abort()
req.raw.on("close", onClose)
reply.hijack()
req.raw.setTimeout(0)
reply.raw.setTimeout(0)
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
const allowed = env.CORS_ORIGIN
const sseHeaders: Record<string, string> = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
}
if (origin && (allowed === "*" || allowed === origin)) {
sseHeaders["Access-Control-Allow-Origin"] = origin
sseHeaders["Access-Control-Allow-Credentials"] = "true"
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
sseHeaders.Vary = "Origin"
}
reply.raw.writeHead(200, sseHeaders)
reply.raw.write(":\n\n")
try {
while (!abort.signal.aborted) {
const payload = safeBuildLiveFlowSample(liveQuery)
writeSse(reply.raw, payload.event, payload.data)
await sleep(LIVE_TICK_MS, abort.signal)
}
} catch {
/* abort / disconnect */
} finally {
releaseFlowLiveSlot()
req.raw.off("close", onClose)
try {
reply.raw.end()
} catch {
/* already closed */
}
}
})
}
export default trafficFlowRoutes
+267 -114
View File
@@ -1,3 +1,4 @@
import { env } from "../config.js"
import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
@@ -12,6 +13,19 @@ import {
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
import { refreshScheduler } from "../services/scheduler.js"
import { appendEvent } from "../modules/events/service/events-service.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js"
import {
bpsToMbps,
buildTrafficFromSamples,
isLoopbackName,
parseMonitorTraffic,
rateBpsFromDelta,
} from "../services/traffic-rate.js"
import {
buildBoundInterfaceTraffic,
buildUserTrafficList,
} from "../services/traffic-users.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect
@@ -40,14 +54,16 @@ interface TrafficInterfaceDto {
txNow: number
}
function latestSnapshot(serverId: number): SnapshotRow | undefined {
return db
const LIVE_TICK_MS = 1500
const LIVE_ROS_TIMEOUT_MS = 4000
async function latestSnapshot(serverId: number) {
return (await db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt))
.limit(1)
.all()[0]
.limit(1))[0]
}
function rangeToMinutes(range: string | undefined): number {
@@ -61,14 +77,6 @@ function rangeToMinutes(range: string | undefined): number {
}
}
function toSeries(values: number[], target = 60): number[] {
if (values.length === 0) return Array(target).fill(0)
if (values.length === target) return values
if (values.length > target) return values.slice(values.length - target)
const head = Array(target - values.length).fill(values[0] ?? 0)
return [...head, ...values]
}
function buildServerTraffic(
s: typeof servers.$inferSelect,
status: TrafficServerDto["status"],
@@ -82,92 +90,139 @@ function buildServerTraffic(
running: boolean
disabled: boolean
}>,
rangeStartMs: number,
rangeEndMs: number,
onlyInterface?: string,
): TrafficServerDto {
const filteredRows = onlyInterface
? rows.filter((r) => r.interfaceName === onlyInterface)
: rows
if (filteredRows.length === 0) {
return {
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
country: s.country || "UN",
status,
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotal: 0,
txTotal: 0,
sessions: 0,
rxSeries: Array(60).fill(0),
txSeries: Array(60).fill(0),
}
}
const bySampleTs = new Map<string, { rx: number; tx: number }>()
const byIface = new Map<string, typeof filteredRows>()
for (const r of filteredRows) {
const ts = r.sampledAt
const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
cur.rx += Math.max(0, r.rxBps) / 1_000_000
cur.tx += Math.max(0, r.txBps) / 1_000_000
bySampleTs.set(ts, cur)
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
}
const seriesPoints = [...bySampleTs.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
const txSeries = toSeries(seriesPoints.map((p) => p.tx))
const rxNow = rxSeries[rxSeries.length - 1] ?? 0
const txNow = txSeries[txSeries.length - 1] ?? 0
const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
let rxBytesDelta = 0
let txBytesDelta = 0
let sessions = 0
for (const arr of byIface.values()) {
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const first = sorted[0]
const last = sorted[sorted.length - 1]
if (first && last) {
const dRx = last.rxBytes - first.rxBytes
const dTx = last.txBytes - first.txBytes
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
if (last.running && !last.disabled) sessions += 1
}
}
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface)
return {
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
country: s.country || "UN",
status,
rxNow,
txNow,
rxPeak,
txPeak,
rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
sessions,
rxSeries,
txSeries,
rxNow: built.rxNow,
txNow: built.txNow,
rxPeak: built.rxPeak,
txPeak: built.txPeak,
rxTotal: built.rxTotalGiB,
txTotal: built.txTotalGiB,
sessions: built.sessions,
rxSeries: built.rxSeries,
txSeries: built.txSeries,
}
}
async function snapshotStatus(serverId: number): Promise<TrafficServerDto["status"]> {
const snap = await latestSnapshot(serverId)
return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
}
function ifaceNowMbps(
prev: { rxBytes: number; txBytes: number; sampledAt: string } | undefined,
last: { rxBytes: number; txBytes: number; sampledAt: string; rxBps: number; txBps: number },
): { rxNow: number; txNow: number } {
if (!prev) {
return { rxNow: bpsToMbps(last.rxBps), txNow: bpsToMbps(last.txBps) }
}
const t0 = Date.parse(prev.sampledAt)
const t1 = Date.parse(last.sampledAt)
const rxBps = rateBpsFromDelta(prev.rxBytes, last.rxBytes, t0, t1)
const txBps = rateBpsFromDelta(prev.txBytes, last.txBytes, t0, t1)
return {
rxNow: bpsToMbps(rxBps ?? last.rxBps),
txNow: bpsToMbps(txBps ?? last.txBps),
}
}
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error("aborted"))
return
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
reject(new Error("aborted"))
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
function flattenMonitor(raw: unknown): unknown[] {
if (Array.isArray(raw)) return raw
if (raw != null) return [raw]
return []
}
async function listRunningIfaceNames(client: MikrotikClient): Promise<string[]> {
const ifaces = await client.get<Array<{ name?: string; running?: string; disabled?: string }>>(
"/interface",
LIVE_ROS_TIMEOUT_MS,
)
return ifaces
.filter((i) => (i.running ?? "false") === "true"
&& (i.disabled ?? "false") !== "true"
&& !isLoopbackName(i.name ?? ""))
.map((i) => i.name ?? "")
.filter(Boolean)
}
async function monitorTrafficOnce(
client: MikrotikClient,
onlyInterface: string | undefined,
cache: { names: string[]; joinedFailed: boolean },
signal: AbortSignal,
): Promise<unknown> {
if (onlyInterface) {
return client.post(
"/interface/monitor-traffic",
{ interface: onlyInterface, once: "" },
LIVE_ROS_TIMEOUT_MS,
signal,
)
}
if (cache.names.length === 0) {
cache.names = await listRunningIfaceNames(client)
}
if (cache.names.length === 0) return []
if (!cache.joinedFailed) {
try {
return await client.post(
"/interface/monitor-traffic",
{ interface: cache.names.join(","), once: "" },
LIVE_ROS_TIMEOUT_MS,
signal,
)
} catch {
cache.joinedFailed = true
}
}
const chunks = await Promise.all(
cache.names.map((name) =>
client.post(
"/interface/monitor-traffic",
{ interface: name, once: "" },
LIVE_ROS_TIMEOUT_MS,
signal,
).then(flattenMonitor).catch(() => [] as unknown[]),
),
)
return chunks.flat()
}
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/settings", async (_req, reply) => {
const settings = getTrafficSettings()
const state = getTrafficCollectorState()
const settings = await getTrafficSettings()
const state = await getTrafficCollectorState()
return reply.send({
enabled: settings.enabled,
intervalSec: settings.intervalSec,
@@ -187,12 +242,12 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
}
const intervalSec = body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 30)
const retentionDays = body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14)
const updated = updateTrafficSettings({
const updated = await updateTrafficSettings({
enabled: body.enabled,
intervalSec,
retentionDays,
})
refreshScheduler()
await refreshScheduler()
return reply.send({
ok: true,
settings: {
@@ -210,8 +265,8 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
try {
await collectTrafficOnce()
scheduleAlertEngineAfterDataCollectors()
const updated = getTrafficSettings()
appendEvent({
const updated = await getTrafficSettings()
await appendEvent({
level: "info",
eventType: "traffic.collect.manual.ok",
sourceModule: "traffic",
@@ -229,7 +284,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
})
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
appendEvent({
await appendEvent({
level: "critical",
eventType: "traffic.collect.manual.failed",
sourceModule: "traffic",
@@ -243,33 +298,91 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/servers", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const data = allServers.map((s): TrafficServerDto => {
const snap = latestSnapshot(s.id)
const status: TrafficServerDto["status"] =
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
const rows = readServerSamplesInRange(s.id, sinceIso)
return buildServerTraffic(s, status, rows)
})
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
const sinceIso = new Date(rangeStartMs).toISOString()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const data = await Promise.all(allServers.map(async (s): Promise<TrafficServerDto> => {
const rows = await readServerSamplesInRange(s.id, sinceIso)
return buildServerTraffic(s, await snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs)
}))
return reply.send({ servers: data })
})
app.get("/traffic/servers/:id/live", async (req, reply) => {
const p = req.params as { id?: string | number }
const q = req.query as { iface?: string }
const server = await getEnabledServerById(p.id ?? "")
if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" })
const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined
const abort = new AbortController()
const onClose = () => abort.abort()
req.raw.on("close", onClose)
reply.hijack()
req.raw.setTimeout(0)
reply.raw.setTimeout(0)
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
const allowed = env.CORS_ORIGIN
const sseHeaders: Record<string, string> = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
}
if (origin && (allowed === "*" || allowed === origin)) {
sseHeaders["Access-Control-Allow-Origin"] = origin
sseHeaders["Access-Control-Allow-Credentials"] = "true"
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
sseHeaders.Vary = "Origin"
}
reply.raw.writeHead(200, sseHeaders)
reply.raw.write(":\n\n")
const client = MikrotikClient.fromServer(server)
const cache = { names: onlyInterface ? [onlyInterface] : [] as string[], joinedFailed: false }
try {
while (!abort.signal.aborted) {
try {
const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal)
const sample = parseMonitorTraffic(raw, { onlyInterface })
writeSse(reply.raw, "sample", sample)
} catch (error) {
if (abort.signal.aborted) break
const msg = error instanceof Error ? error.message : String(error)
writeSse(reply.raw, "error", { error: msg })
}
await sleep(LIVE_TICK_MS, abort.signal)
}
} catch {
/* abort / disconnect */
} finally {
req.raw.off("close", onClose)
try {
reply.raw.end()
} catch {
/* already closed */
}
}
})
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
const p = req.params as { id?: string | number }
const serverId = Number.parseInt(String(p.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
const allRows = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()
const allRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
const server = allRows[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const sinceIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const rows = readServerSamplesInRange(serverId, sinceIso)
const rows = await readServerSamplesInRange(serverId, sinceIso)
const byIface = new Map<string, typeof rows>()
for (const r of rows) {
if (isLoopbackName(r.interfaceName)) continue
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
@@ -277,12 +390,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1]
const prev = sorted[sorted.length - 2]
if (!last) {
return { name, running: false, disabled: true, rxNow: 0, txNow: 0 }
}
const now = ifaceNowMbps(prev, last)
return {
name,
running: Boolean(last?.running),
disabled: Boolean(last?.disabled),
rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000),
txNow: Math.round((last?.txBps ?? 0) / 1_000_000),
running: Boolean(last.running),
disabled: Boolean(last.disabled),
rxNow: now.rxNow,
txNow: now.txNow,
}
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
@@ -294,19 +412,54 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const q = req.query as { range?: string; iface?: string }
const serverId = Number.parseInt(String(p.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const rows = readServerSamplesInRange(server.id, sinceIso)
const snap = latestSnapshot(server.id)
const status: TrafficServerDto["status"] =
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
const sinceIso = new Date(rangeStartMs).toISOString()
const rows = await readServerSamplesInRange(server.id, sinceIso)
const data = buildServerTraffic(
server,
await snapshotStatus(server.id),
rows,
rangeStartMs,
rangeEndMs,
q.iface && q.iface !== "__all__" ? q.iface : undefined,
)
return reply.send({ server: data })
})
app.get("/traffic/users", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
return reply.send({ users: await buildUserTrafficList(rangeStartMs, rangeEndMs) })
})
app.get("/traffic/users/:id", async (req, reply) => {
const p = req.params as { id?: string }
const q = req.query as { range?: string }
const id = String(p.id ?? "")
if (!id) return reply.status(400).send({ error: "id is required" })
const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
const users = await buildUserTrafficList(rangeStartMs, rangeEndMs)
const user = users.find((u) => u.id === id)
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
return reply.send({ user })
})
app.get("/traffic/bound-interfaces", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
return reply.send({ interfaces: await buildBoundInterfaceTraffic(rangeStartMs, rangeEndMs) })
})
}
export default trafficRoutes
+33 -33
View File
@@ -36,8 +36,8 @@ function toSeries(values: number[], target = 40): number[] {
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
}
/** Сэмпл ресурсов из SQLite (readResourceSamplesSince). */
type ResourceSampleRow = ReturnType<typeof readResourceSamplesSince>[number]
/** Сэмпл ресурсов из PostgreSQL (readResourceSamplesSince). */
type ResourceSampleRow = Awaited<ReturnType<typeof readResourceSamplesSince>>[number]
/**
* Последний сэмпл по времени может быть offline после сбоя опроса тогда UI «пустой».
@@ -67,7 +67,7 @@ function pickResourceDisplayRow(rows: ResourceSampleRow[], maxGapMs: number): {
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/uptime/settings", async (_req, reply) => {
const s = readUptimeSettings()
const s = await readUptimeSettings()
return reply.send({
enabled: s.enabled,
resourcesEnabled: s.resourcesEnabled ?? s.enabled,
@@ -82,7 +82,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
lastCollectedAt: s.lastCollectedAt ?? null,
lastDurationMs: s.lastDurationMs ?? null,
lastError: s.lastError || null,
scheduler: getSchedulerStatus(),
scheduler: await getSchedulerStatus(),
})
})
@@ -97,7 +97,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
speedIntervalSec?: number | string
retentionDays?: number | string
}
const updated = updateUptimeSettings({
const updated = await updateUptimeSettings({
enabled: body.enabled,
resourcesEnabled: body.resourcesEnabled,
pingEnabled: body.pingEnabled,
@@ -107,7 +107,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
speedIntervalSec: body.speedIntervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.speedIntervalSec), 10) || 60),
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
})
refreshScheduler()
await refreshScheduler()
return reply.send({
ok: true,
settings: {
@@ -122,7 +122,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
scheduler: getSchedulerStatus(),
scheduler: await getSchedulerStatus(),
},
})
})
@@ -130,7 +130,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/uptime/collect-now", async (_req, reply) => {
await collectUptimeOnce()
scheduleAlertEngineAfterDataCollectors()
const s = readUptimeSettings()
const s = await readUptimeSettings()
return reply.send({
ok: true,
lastCollectedAt: s.lastCollectedAt ?? null,
@@ -158,7 +158,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.get("/uptime/probes", async (_req, reply) => {
const rows = readProbeRows()
const rows = await readProbeRows()
return reply.send({
probes: rows.map((p) => ({
id: p.id,
@@ -178,7 +178,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.get("/uptime/speed-probes", async (_req, reply) => {
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all()
const rows = await db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder))
return reply.send({
probes: rows.map((r) => ({
id: r.id,
@@ -204,7 +204,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.get("/uptime/speed-test/runs", async (_req, reply) => {
const rows = db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200).all()
const rows = await db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200)
return reply.send({
runs: rows.map((r) => ({
id: r.id,
@@ -263,12 +263,12 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
.filter((p) => Number.isFinite(p.srcServerId) && Number.isFinite(p.dstServerId) && p.srcServerId !== p.dstServerId)
const now = new Date().toISOString()
const existing = db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes).all()
const existing = await db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes)
const nextIds = new Set(normalized.map((p) => p.id))
for (const row of existing) {
if (nextIds.has(row.id)) continue
db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id)).run()
await db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id))
}
for (let i = 0; i < normalized.length; i += 1) {
@@ -285,15 +285,15 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
sortOrder: i,
updatedAt: now,
}
const updated = db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).run()
if ((updated.changes ?? 0) > 0) continue
db.insert(uptimeSpeedProbes).values({
const updated = await db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).returning({ id: uptimeSpeedProbes.id })
if (updated.length > 0) continue
await db.insert(uptimeSpeedProbes).values({
id: p.id,
...patch,
createdAt: now,
}).run()
})
}
refreshScheduler()
await refreshScheduler()
return reply.send({ ok: true })
})
@@ -326,8 +326,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
: Number.parseInt(String(p.intervalSec), 10),
}))
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
replaceProbes(normalized)
refreshScheduler()
await replaceProbes(normalized)
await refreshScheduler()
return reply.send({ ok: true })
})
@@ -339,7 +339,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
if (typeof body.showOnDashboard !== "boolean") {
return reply.status(400).send({ error: "showOnDashboard boolean required" })
}
const ok = updateProbeShowOnDashboard(probeId, body.showOnDashboard)
const ok = await updateProbeShowOnDashboard(probeId, body.showOnDashboard)
if (!ok) return reply.status(404).send({ error: "Probe not found" })
return reply.send({ ok: true })
})
@@ -348,7 +348,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!srv) return reply.status(404).send({ error: "Server not found" })
try {
const rows = await MikrotikClient.fromServer(srv).getInterfaces()
@@ -386,8 +386,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Invalid src/dst server id" })
}
if (srcId === dstId) return reply.status(400).send({ error: "Source and destination must be different" })
const src = db.select().from(servers).where(eq(servers.id, srcId)).limit(1).all()[0]
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
const src = (await db.select().from(servers).where(eq(servers.id, srcId)).limit(1))[0]
const dst = (await db.select().from(servers).where(eq(servers.id, dstId)).limit(1))[0]
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
try {
const result = await runBandwidthSpeedTest({
@@ -404,7 +404,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ ok: true, result })
} catch (e) {
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
recordSpeedTestFailure({
await recordSpeedTestFailure({
probeId: String(body.probeId ?? "").trim() || undefined,
runId: String(body.runId ?? "").trim() || undefined,
srcServerId: srcId,
@@ -430,7 +430,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!srv) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(srv)
@@ -473,10 +473,10 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const allServers = db.select().from(servers).all()
const probeRows = readProbeRows()
const probeSamples = readProbeSamplesSince(sinceIso)
const uptimeCfg = readUptimeSettings()
const allServers = await db.select().from(servers)
const probeRows = await readProbeRows()
const probeSamples = await readProbeSamplesSince(sinceIso)
const uptimeCfg = await readUptimeSettings()
const intervalSec = Math.max(15, uptimeCfg.intervalSec ?? 300)
/** Допустимый разрыв между последней попыткой и последним «хорошим» сэмплом (не показывать данные часовой давности). */
const resourceFallbackMaxGapMs = Math.min(2 * 3600 * 1000, Math.max(10 * 60 * 1000, intervalSec * 6 * 1000))
@@ -501,8 +501,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
}
})
const resources = allServers.map((s) => {
const rows = readResourceSamplesSince(sinceIso, s.id)
const resources = await Promise.all(allServers.map(async (s) => {
const rows = await readResourceSamplesSince(sinceIso, s.id)
const { row: pick, hasData } = pickResourceDisplayRow(rows, resourceFallbackMaxGapMs)
const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40)
return {
@@ -518,7 +518,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
boardName: pick?.boardName || "RouterBOARD",
temp: undefined as number | undefined,
}
})
}))
return reply.send({ probes, resources })
})
+117
View File
@@ -0,0 +1,117 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
appUserCreateSchema,
appUserIdParamSchema,
appUserUpdateSchema,
bindingIdParamSchema,
interfaceCatalogQuerySchema,
userBindingCreateSchema,
type AppUserCreate,
type AppUserUpdate,
type UserBindingCreate,
} from "@mmapp/contracts/users"
import {
addBinding,
createUser,
deleteUser,
getUserById,
listInterfaceCatalog,
listUsers,
removeBinding,
updateUser,
UsersServiceError,
} from "../modules/users/service/users-service.js"
function sendServiceError(reply: { status: (c: number) => { send: (b: unknown) => unknown } }, err: unknown) {
if (err instanceof UsersServiceError) {
return reply.status(err.status).send({ error: err.message })
}
throw err
}
const usersRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/users", async (_req, reply) => {
return reply.send({ users: await listUsers() })
})
app.get("/users/interface-catalog", {
schema: { querystring: interfaceCatalogQuerySchema },
}, async (req, reply) => {
const q = req.query as { serverId: number }
try {
return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.get("/users/:id", {
schema: { params: appUserIdParamSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
const user = await getUserById(id)
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
return reply.send({ user })
})
app.post("/users", {
schema: { body: appUserCreateSchema },
}, async (req, reply) => {
try {
const user = await createUser(req.body as AppUserCreate)
return reply.status(201).send({ user })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.patch("/users/:id", {
schema: { params: appUserIdParamSchema, body: appUserUpdateSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
try {
const user = await updateUser(id, req.body as AppUserUpdate)
return reply.send({ user })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.delete("/users/:id", {
schema: { params: appUserIdParamSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
try {
await deleteUser(id)
return reply.status(204).send()
} catch (err) {
return sendServiceError(reply, err)
}
})
app.post("/users/:id/bindings", {
schema: { params: appUserIdParamSchema, body: userBindingCreateSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
try {
const binding = await addBinding(id, req.body as UserBindingCreate)
return reply.status(201).send({ binding })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.delete("/users/:id/bindings/:bindingId", {
schema: { params: bindingIdParamSchema },
}, async (req, reply) => {
const { id, bindingId } = req.params as { id: string; bindingId: string }
try {
await removeBinding(id, bindingId)
return reply.status(204).send()
} catch (err) {
return sendServiceError(reply, err)
}
})
}
export default usersRoutes
+30 -44
View File
@@ -21,6 +21,12 @@ import {
getEnabledServerById,
listWireGuardInterfaces,
} from "../services/wireguard-live.js"
import {
putIpAddress,
putWireguardInterface,
putWireguardPeer,
toRosBody,
} from "../services/wireguard-ros.js"
function serverIdParam(v: string): string {
return decodeURIComponent(v)
@@ -30,14 +36,6 @@ function rosIdParam(v: string): string {
return decodeURIComponent(v)
}
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== "") out[k] = v
}
return out
}
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
return toRosBody({
interface: p.interfaceName,
@@ -99,20 +97,17 @@ async function applyParsedConfig(
comment: parsed.interface.comment,
disabled: parsed.interface.disabled ? "yes" : undefined,
})
await client.put("/interface/wireguard", ifaceBody)
await putWireguardInterface(client, ifaceBody)
if (parsed.interface.address) {
await client.put("/ip/address", {
address: parsed.interface.address,
interface: name,
})
await putIpAddress(client, parsed.interface.address, name)
}
let peersCreated = 0
for (const p of parsed.peers) {
if (!p.publicKey) continue
await client.put(
"/interface/wireguard/peers",
await putWireguardPeer(
client,
peerToRosBody({
interfaceName: name,
publicKey: p.publicKey,
@@ -159,35 +154,26 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put(
"/interface/wireguard",
toRosBody({
name: body.name,
"listen-port": String(body.listenPort),
mtu: String(body.mtu),
comment: body.comment,
"private-key": body.privateKey,
disabled: body.disabled ? "yes" : undefined,
}),
)
await putWireguardInterface(client, {
name: body.name,
"listen-port": String(body.listenPort),
mtu: String(body.mtu),
comment: body.comment,
"private-key": body.privateKey,
disabled: body.disabled ? "yes" : undefined,
})
if (body.address) {
await client.put("/ip/address", {
address: body.address,
interface: body.name,
})
await putIpAddress(client, body.address, body.name)
}
if (body.peer) {
await client.put(
"/interface/wireguard/peers",
peerToRosBody({ ...body.peer, interfaceName: body.name }),
)
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
}
const list = await listWireGuardInterfaces({
@@ -208,7 +194,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const server = getEnabledServerById(serverIdParam(serverId))
const server = await getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const d = parsed.data
@@ -233,7 +219,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId))
const server = await getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
@@ -251,11 +237,11 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put("/interface/wireguard/peers", peerToRosBody(body))
await putWireguardPeer(client, peerToRosBody(body))
return reply.status(201).send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -269,7 +255,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const server = getEnabledServerById(serverIdParam(serverId))
const server = await getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const d = parsed.data
const client = MikrotikClient.fromServer(server)
@@ -300,7 +286,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId))
const server = await getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
@@ -329,7 +315,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send({ dryRun: true, preview })
}
const server = getEnabledServerById(body.serverId)
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
@@ -347,14 +333,14 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const list = await listWireGuardInterfaces({
serverId: String(server.id),
includePrivateKey: body.includePrivateKey === true,
})
const iface = findIface(list.interfaces, String(server.id), body.interfaceName)
const iface = await findIface(list.interfaces, String(server.id), body.interfaceName)
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
if (body.format === "rsc") {
@@ -0,0 +1,20 @@
import { env } from "../config.js"
import { closePool, pool } from "../db/index.js"
import { applySqlMigrations } from "../db/migrate.js"
import { ensurePartitionsAround } from "../db/partitions.js"
import { importSqliteToPostgres } from "../db/sqlite-import.js"
const sqlitePath = process.argv.find((a) => a.startsWith("--sqlite="))?.slice(9) ?? env.DATABASE_PATH
const dryRun = process.argv.includes("--dry-run")
const fullHistory = process.argv.includes("--full-history")
const strict = process.argv.includes("--strict") || !dryRun
await applySqlMigrations(pool)
await ensurePartitionsAround(pool)
const report = await importSqliteToPostgres(pool, sqlitePath, { dryRun, strict, fullHistory })
console.log(JSON.stringify(report, null, 2))
if (!dryRun && report.rejects.length > 0 && strict) {
await closePool()
process.exit(1)
}
await closePool()
+9 -13
View File
@@ -85,15 +85,11 @@ async function deleteTxtRecord(token: string, zoneId: string, recordId: string):
await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" })
}
function parseWanUplinks(raw: string | null | undefined): WanUplinkRow[] {
if (!raw?.trim()) return []
try {
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
} catch {
return []
}
function parseWanUplinks(raw: unknown): WanUplinkRow[] {
const data = Array.isArray(raw) ? raw : typeof raw === "string" && raw.trim()
? (() => { try { const p = JSON.parse(raw) as unknown; return Array.isArray(p) ? p : [] } catch { return [] } })()
: []
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
}
function normalizeIpv4(raw: string | undefined): string | null {
@@ -217,10 +213,10 @@ async function sleep(ms: number) {
}
async function getOrCreateAccountKey(): Promise<Buffer> {
const existing = getAcmeAccountPrivateKey()
const existing = await getAcmeAccountPrivateKey()
if (existing) return Buffer.from(existing)
const key = await acme.crypto.createPrivateKey()
saveAcmeAccountPrivateKey(key.toString("utf8"))
await saveAcmeAccountPrivateKey(key.toString("utf8"))
return key
}
@@ -247,8 +243,8 @@ export async function issueCertificateWithCloudflareDns(params: {
trustStore: string[]
onStep?: (step: string) => void
}): Promise<void> {
const settings = getAcmeSettingsPublic()
const token = getAcmeCloudflareToken()
const settings = await getAcmeSettingsPublic()
const token = await getAcmeCloudflareToken()
if (!token) throw new Error("Не настроен Cloudflare API token")
const domains = [...new Set(params.domainNames.map((d) => d.trim().toLowerCase()).filter(Boolean))]
@@ -1,5 +1,5 @@
/**
* Внутренние хуки: после записи сигналов в SQLite (коллекторы) отложенный прогон `alert_engine`.
* Внутренние хуки: после записи сигналов в PostgreSQL (коллекторы) отложенный прогон `alert_engine`.
* Не импортирует `scheduler.ts` (избегаем цикла); runner задаётся через `wireAlertEngineRunner`.
*
* Любой код, пишущий таблицы для `buildSignalSnapshot`, после успешного коммита должен вызывать
@@ -15,7 +15,7 @@ let alertAfterCollectChain: Promise<void> = Promise.resolve()
let alertEngineRunner: (() => Promise<void>) | null = null
/** Задать runner один раз при старте приложения (обычно `() => executeSchedulerJob("alert_engine")`). */
/** Задать runner один раз при старте приложения (обычно `() => await executeSchedulerJob("alert_engine")`). */
export function wireAlertEngineRunner(run: () => Promise<void>): void {
alertEngineRunner = run
}
@@ -39,7 +39,7 @@ async function runAlertEngineWhenIdle(): Promise<void> {
/** После успешной записи сигналов в БД коллекторами (не из `alert_engine`). */
export function scheduleAlertEngineAfterDataCollectors(): void {
if (alertAfterCollectTimer != null) clearTimeout(alertAfterCollectTimer)
alertAfterCollectTimer = setTimeout(() => {
alertAfterCollectTimer = setTimeout(async () => {
alertAfterCollectTimer = null
alertAfterCollectChain = alertAfterCollectChain
.then(() => runAlertEngineWhenIdle())
@@ -4,11 +4,10 @@ import { db } from "../../db/index.js"
import { alertEngineConfirmPending } from "../../db/schema.js"
import type { ApiAlertRule } from "../alerts-service.js"
import { computeStabilityReadyMap, deleteConfirmPending } from "./confirm-stability.js"
import { withPgOrSkip } from "../../test/pg.js"
const ruleId = "test-confirm-stability-recovery-bypass"
deleteConfirmPending(ruleId)
const baseRule: ApiAlertRule = {
id: ruleId,
name: "Server transition",
@@ -46,25 +45,31 @@ const recoveryHit = {
payloadHash: "recovery-1",
}
if (!(await withPgOrSkip())) {
console.log("alert-engine confirm-stability tests skip")
process.exit(0)
}
await deleteConfirmPending(ruleId)
{
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]]))
const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]]))
assert.equal(m.get(ruleId), false, "проблемный переход должен ждать confirmStabilitySec")
}
{
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]]))
const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]]))
assert.equal(m.get(ruleId), true, "восстановление должно отправляться без задержки")
}
{
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, null]]))
const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, null]]))
assert.equal(m.get(ruleId), false, "без hit отправка не готова")
const left = db
const left = (await db
.select()
.from(alertEngineConfirmPending)
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
.limit(1)
.all()[0]
.limit(1))[0]
assert.equal(left, undefined, "pending должен очищаться после отсутствия hit")
}
@@ -4,46 +4,44 @@ import { alertEngineConfirmPending } from "../../db/schema.js"
import type { ApiAlertRule } from "../alerts-service.js"
import type { RuleEvalHit } from "./types.js"
export function deleteConfirmPending(ruleId: string) {
db.delete(alertEngineConfirmPending).where(eq(alertEngineConfirmPending.ruleId, ruleId)).run()
export async function deleteConfirmPending(ruleId: string) {
await db.delete(alertEngineConfirmPending).where(eq(alertEngineConfirmPending.ruleId, ruleId))
}
/**
* Обновляет состояние ожидания стабильности и возвращает, можно ли отправлять уведомление в этом тике.
* При отсутствии срабатывания (`hit == null`) ожидание сбрасывается.
*/
export function stabilityReadyForSend(
export async function stabilityReadyForSend(
ruleId: string,
hit: RuleEvalHit | null,
delaySec: number | null | undefined,
): boolean {
): Promise<boolean> {
if (!hit) {
deleteConfirmPending(ruleId)
await deleteConfirmPending(ruleId)
return false
}
const sec = delaySec != null && delaySec > 0 ? Math.floor(delaySec) : 0
if (sec <= 0) {
deleteConfirmPending(ruleId)
await deleteConfirmPending(ruleId)
return true
}
const now = Date.now()
const nowIso = new Date().toISOString()
const existing = db
const existing = (await db
.select()
.from(alertEngineConfirmPending)
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
.limit(1)
.all()[0]
.limit(1))[0]
if (!existing || existing.payloadHash !== hit.payloadHash) {
if (existing) {
db.update(alertEngineConfirmPending)
await db.update(alertEngineConfirmPending)
.set({ payloadHash: hit.payloadHash, sinceAt: nowIso })
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
.run()
} else {
db.insert(alertEngineConfirmPending).values({ ruleId, payloadHash: hit.payloadHash, sinceAt: nowIso }).run()
await db.insert(alertEngineConfirmPending).values({ ruleId, payloadHash: hit.payloadHash, sinceAt: nowIso })
}
return false
}
@@ -54,18 +52,18 @@ export function stabilityReadyForSend(
}
/** После успешной отправки — сбросить таймер стабильности для следующего цикла. */
export function clearConfirmPendingAfterSend(ruleIds: string[]) {
for (const id of ruleIds) deleteConfirmPending(id)
export async function clearConfirmPendingAfterSend(ruleIds: string[]) {
for (const id of ruleIds) await deleteConfirmPending(id)
}
export function computeStabilityReadyMap(
export async function computeStabilityReadyMap(
rules: ApiAlertRule[],
hitByRule: Map<string, RuleEvalHit | null>,
): Map<string, boolean> {
): Promise<Map<string, boolean>> {
const m = new Map<string, boolean>()
for (const rule of rules) {
if (!rule.enabled) {
deleteConfirmPending(rule.id)
await deleteConfirmPending(rule.id)
m.set(rule.id, false)
continue
}
@@ -76,7 +74,7 @@ export function computeStabilityReadyMap(
? rule.recoveryStabilitySec
: 0
: rule.confirmStabilitySec
m.set(rule.id, stabilityReadyForSend(rule.id, hit, delaySec))
m.set(rule.id, await stabilityReadyForSend(rule.id, hit, delaySec))
}
return m
}
@@ -73,20 +73,20 @@ export function buildRuleDiagnostics(
return out
}
export function computeDecisions(args: {
export async function computeDecisions(args: {
rules: ApiAlertRule[]
groups: ApiAlertGroup[]
hitByRule: Map<string, RuleEvalHit | null>
state: EngineStateMap
canSend: boolean
}): {
}): Promise<{
stabilityReady: Map<string, boolean>
standalone: StandaloneDecision[]
grouped: GroupDecision[]
ruleDiag: AlertEngineRuleDiag[]
} {
}> {
const { rules, groups, hitByRule, state, canSend } = args
const stabilityReady = computeStabilityReadyMap(rules, hitByRule)
const stabilityReady = await computeStabilityReadyMap(rules, hitByRule)
const ruleDiag = buildRuleDiagnostics(rules, hitByRule, stabilityReady, state, canSend)
const standalone: StandaloneDecision[] = []
const grouped: GroupDecision[] = []
+15 -19
View File
@@ -17,7 +17,9 @@ export type AlertOutboxPayload = {
}
}
function safeParsePayload(raw: string): AlertOutboxPayload | null {
function safeParsePayload(raw: unknown): AlertOutboxPayload | null {
if (raw && typeof raw === "object") return raw as AlertOutboxPayload
if (typeof raw !== "string") return null
try {
return JSON.parse(raw) as AlertOutboxPayload
} catch {
@@ -25,21 +27,20 @@ function safeParsePayload(raw: string): AlertOutboxPayload | null {
}
}
export function enqueueTelegramOutbox(input: {
export async function enqueueTelegramOutbox(input: {
id: string
dedupeKey: string
payload: AlertOutboxPayload
maxRetries?: number
}): boolean {
const existing = db
}): Promise<boolean> {
const existing = (await db
.select()
.from(alertOutbox)
.where(eq(alertOutbox.dedupeKey, input.dedupeKey))
.limit(1)
.all()[0]
.limit(1))[0]
if (existing && (existing.status === "pending" || existing.status === "sent")) return false
const nowIso = new Date().toISOString()
db.insert(alertOutbox)
await db.insert(alertOutbox)
.values({
id: input.id,
dedupeKey: input.dedupeKey,
@@ -48,22 +49,20 @@ export function enqueueTelegramOutbox(input: {
retryCount: 0,
maxRetries: Math.max(1, Math.floor(input.maxRetries ?? 3)),
nextAttemptAt: nowIso,
payloadJson: JSON.stringify(input.payload),
payloadJson: input.payload,
})
.run()
return true
}
export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; failed: number; errors: string[] }> {
return (async () => {
const nowIso = new Date().toISOString()
const rows = db
const rows = await db
.select()
.from(alertOutbox)
.where(and(eq(alertOutbox.status, "pending"), lte(alertOutbox.nextAttemptAt, nowIso)))
.orderBy(asc(alertOutbox.createdAt))
.limit(Math.max(1, limit))
.all()
let sent = 0
let failed = 0
@@ -72,27 +71,25 @@ export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; faile
for (const row of rows) {
const payload = safeParsePayload(row.payloadJson)
if (!payload) {
db.update(alertOutbox)
await db.update(alertOutbox)
.set({
status: "failed",
lastError: "invalid payload_json",
})
.where(eq(alertOutbox.id, row.id))
.run()
failed += 1
continue
}
const send = await sendTelegramAlertMessage({ text: payload.text, chatId: payload.chatId })
if (send.ok) {
db.update(alertOutbox)
await db.update(alertOutbox)
.set({
status: "sent",
sentAt: new Date().toISOString(),
lastError: null,
})
.where(eq(alertOutbox.id, row.id))
.run()
appendAlertHistory({
await appendAlertHistory({
id: payload.history.id,
ruleId: payload.history.ruleId ?? null,
groupId: payload.history.groupId ?? null,
@@ -107,7 +104,7 @@ export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; faile
}
const nextRetry = row.retryCount + 1
const exhausted = nextRetry >= row.maxRetries
db.update(alertOutbox)
await db.update(alertOutbox)
.set({
retryCount: nextRetry,
status: exhausted ? "failed" : "pending",
@@ -115,9 +112,8 @@ export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; faile
lastError: send.error,
})
.where(eq(alertOutbox.id, row.id))
.run()
if (exhausted) {
appendAlertHistory({
await appendAlertHistory({
id: payload.history.id,
ruleId: payload.history.ruleId ?? null,
groupId: payload.history.groupId ?? null,
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm"
import { db } from "../../db/index.js"
import { parseJsonObject } from "../../db/json.js"
import { alertEnginePrevLive } from "../../db/schema.js"
export type PrevLiveKind = "gre" | "bgp"
@@ -7,41 +8,32 @@ export type PrevLiveKind = "gre" | "bgp"
/** Предыдущие строковые состояния (GRE: up|down|degraded; BGP: state как с роутера). */
export type PrevLiveStringMap = Record<string, string>
function safeParseMap(json: string | null | undefined): PrevLiveStringMap {
if (!json || json.trim() === "") return {}
try {
const v = JSON.parse(json) as unknown
if (v == null || typeof v !== "object" || Array.isArray(v)) return {}
return v as PrevLiveStringMap
} catch {
return {}
}
function safeParseMap(json: unknown): PrevLiveStringMap {
return parseJsonObject(json) as PrevLiveStringMap
}
export function loadPrevLiveMap(kind: PrevLiveKind): PrevLiveStringMap {
const row = db
export async function loadPrevLiveMap(kind: PrevLiveKind): Promise<PrevLiveStringMap> {
const row = (await db
.select()
.from(alertEnginePrevLive)
.where(eq(alertEnginePrevLive.kind, kind))
.limit(1)
.all()[0]
.limit(1))[0]
return safeParseMap(row?.payloadJson)
}
export function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
const payloadJson = JSON.stringify(map)
const existing = db
export async function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
const payloadJson = map
const existing = (await db
.select()
.from(alertEnginePrevLive)
.where(eq(alertEnginePrevLive.kind, kind))
.limit(1)
.all()[0]
.limit(1))[0]
if (existing) {
db.update(alertEnginePrevLive)
if (existing.payloadJson === payloadJson) return
await db.update(alertEnginePrevLive)
.set({ payloadJson, updatedAt: new Date().toISOString() })
.where(eq(alertEnginePrevLive.kind, kind))
.run()
} else {
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
return
}
await db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() })
}
+24 -25
View File
@@ -26,7 +26,7 @@ const SNAPSHOT_SOURCE_WAIT_MS = 30_000
const SNAPSHOT_SOURCE_POLL_MS = 20
/**
* Не строить снимок, пока коллекторы пишут в SQLite иначе гонка с `alert_engine` по таймеру
* Не строить снимок, пока коллекторы пишут в PostgreSQL иначе гонка с `alert_engine` по таймеру
* (в т.ч. слот планировщика занят до первого `await` в коллекторе, когда внутренний `collecting` ещё false).
*/
async function awaitSnapshotSourcesIdle(): Promise<void> {
@@ -52,8 +52,8 @@ function newOutboxId(): string {
return `ao-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function loadEngineStateMap(): Map<string, { lastFiredAt: string; lastPayloadHash: string | null }> {
const rows = db.select().from(alertEngineState).all()
async function loadEngineStateMap(): Promise<Map<string, { lastFiredAt: string; lastPayloadHash: string | null }>> {
const rows = await db.select().from(alertEngineState)
const m = new Map<string, { lastFiredAt: string; lastPayloadHash: string | null }>()
for (const r of rows) {
m.set(r.scopeKey, { lastFiredAt: r.lastFiredAt, lastPayloadHash: r.lastPayloadHash ?? null })
@@ -61,15 +61,14 @@ function loadEngineStateMap(): Map<string, { lastFiredAt: string; lastPayloadHas
return m
}
function upsertEngineState(scopeKey: string, lastFiredAt: string, lastPayloadHash: string | null) {
const existing = db.select().from(alertEngineState).where(eq(alertEngineState.scopeKey, scopeKey)).limit(1).all()[0]
async function upsertEngineState(scopeKey: string, lastFiredAt: string, lastPayloadHash: string | null) {
const existing = (await db.select().from(alertEngineState).where(eq(alertEngineState.scopeKey, scopeKey)).limit(1))[0]
if (existing) {
db.update(alertEngineState)
await db.update(alertEngineState)
.set({ lastFiredAt, lastPayloadHash })
.where(eq(alertEngineState.scopeKey, scopeKey))
.run()
} else {
db.insert(alertEngineState).values({ scopeKey, lastFiredAt, lastPayloadHash }).run()
await db.insert(alertEngineState).values({ scopeKey, lastFiredAt, lastPayloadHash })
}
}
@@ -90,16 +89,16 @@ function ruleScopeKey(ruleId: string, transition?: RuleEvalHit["transition"]): s
export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
const errors: string[] = []
const sampledAt = new Date().toISOString()
const latestSourceFinishedAt = getLatestSourceFinishedAt()
const prevSourceFinishedAt = getSourceWatermark()
const latestSourceFinishedAt = await getLatestSourceFinishedAt()
const prevSourceFinishedAt = await getSourceWatermark()
const hasNewSources =
latestSourceFinishedAt != null &&
(prevSourceFinishedAt == null || latestSourceFinishedAt > prevSourceFinishedAt)
const rules = listAlertRules()
const groups = listAlertGroups()
const state = loadEngineStateMap()
const pub = getTelegramPublic()
const rules = await listAlertRules()
const groups = await listAlertGroups()
const state = await loadEngineStateMap()
const pub = await getTelegramPublic()
const canSend = pub.tokenConfigured && Boolean(pub.chatId?.trim())
let standaloneFires = 0
let groupFires = 0
@@ -107,10 +106,10 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
let ruleDiag: AlertEngineRunResult["ruleDiag"] = []
if (hasNewSources) {
await awaitSnapshotSourcesIdle()
const snap = ingestAlertSignals()
const snap = await ingestAlertSignals()
const matched = matchRules(rules, snap)
errors.push(...matched.errors)
const { standalone, grouped, ruleDiag: diag } = computeDecisions({
const { standalone, grouped, ruleDiag: diag } = await computeDecisions({
rules,
groups,
hitByRule: matched.hitByRule,
@@ -123,7 +122,7 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
if (!canSend) break
const firedAt = new Date().toISOString()
const key = ruleScopeKey(d.rule.id, d.hit.transition)
const queued = enqueueTelegramOutbox({
const queued = await enqueueTelegramOutbox({
id: newOutboxId(),
dedupeKey: `${key}:${d.hit.payloadHash}`,
payload: {
@@ -142,8 +141,8 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
})
if (!queued) continue
standaloneFires += 1
clearConfirmPendingAfterSend([d.rule.id])
upsertEngineState(key, firedAt, d.hit.payloadHash)
await clearConfirmPendingAfterSend([d.rule.id])
await upsertEngineState(key, firedAt, d.hit.payloadHash)
state.set(key, { lastFiredAt: firedAt, lastPayloadHash: d.hit.payloadHash })
}
@@ -155,7 +154,7 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
const body = `Группа «${d.group.name}» (${d.group.combineMode === "any" ? "ANY" : "ALL"})\n\n${lines.join("\n")}`
const hash = d.hits.map((h) => h.payloadHash).sort().join("|")
const key = `group:${d.group.id}`
const queued = enqueueTelegramOutbox({
const queued = await enqueueTelegramOutbox({
id: newOutboxId(),
dedupeKey: `${key}:${hash}`,
payload: {
@@ -173,18 +172,18 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
})
if (!queued) continue
groupFires += 1
upsertEngineState(key, firedAt, hash)
await upsertEngineState(key, firedAt, hash)
state.set(key, { lastFiredAt: firedAt, lastPayloadHash: hash })
clearConfirmPendingAfterSend(d.members.map((r) => r.id))
await clearConfirmPendingAfterSend(d.members.map((r) => r.id))
}
updateSourceWatermark(latestSourceFinishedAt)
await updateSourceWatermark(latestSourceFinishedAt)
}
const outbox = await dispatchPendingOutbox()
errors.push(...outbox.errors)
if (standaloneFires > 0 || groupFires > 0) {
appendEvent({
await appendEvent({
level: "info",
eventType: "alerts.engine.fired",
sourceModule: "alerts",
@@ -197,7 +196,7 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
})
}
if (errors.length > 0) {
appendEvent({
await appendEvent({
level: "warning",
eventType: "alerts.engine.errors",
sourceModule: "alerts",
@@ -1,6 +1,6 @@
import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js"
/** Единая точка входа для подготовки сигналов к rule matching. */
export function ingestAlertSignals() {
return buildSignalSnapshotFromCollectors()
export async function ingestAlertSignals() {
return await buildSignalSnapshotFromCollectors()
}
@@ -1,4 +1,4 @@
import { sqliteDatabase } from "../../db/index.js"
import { dbAll } from "../../db/index.js"
import type {
GreBgpSnapshotRunSnapshot,
PingRunSnapshot,
@@ -20,25 +20,23 @@ function normalizeNameKey(v: string): string {
return v.trim().toLowerCase()
}
function safeParseRunSnapshot(raw: string | null): SchedulerRunSnapshot | null {
if (!raw) return null
try {
return JSON.parse(raw) as SchedulerRunSnapshot
} catch {
return null
}
function safeParseRunSnapshot(raw: unknown): SchedulerRunSnapshot | null {
const obj = typeof raw === "string" ? (() => {
try { return JSON.parse(raw) as unknown } catch { return null }
})() : raw
if (!obj || typeof obj !== "object") return null
return obj as SchedulerRunSnapshot
}
function readLatestOkRunSnapshots(jobKey: string, limit = 32): SchedulerRunSnapshot[] {
const rows = sqliteDatabase
.prepare(
`SELECT result_json AS resultJson
FROM scheduler_runs
WHERE job_key = ? AND status = 'ok' AND result_json IS NOT NULL
ORDER BY finished_at DESC
LIMIT ?`,
)
.all(jobKey, limit) as { resultJson: string | null }[]
async function readLatestOkRunSnapshots(jobKey: string, limit = 32): Promise<SchedulerRunSnapshot[]> {
const rows = await dbAll<{ resultJson: unknown }>(
`SELECT result_json AS "resultJson"
FROM scheduler_runs
WHERE job_key = ? AND status = 'ok' AND result_json IS NOT NULL
ORDER BY finished_at DESC
LIMIT ?`,
[jobKey, limit],
)
const out: SchedulerRunSnapshot[] = []
for (const row of rows) {
const parsed = safeParseRunSnapshot(row.resultJson)
@@ -227,13 +225,13 @@ function collectBgpSignalsFromRuns(runs: GreBgpSnapshotRunSnapshot[]): BgpPeerSi
}
/** Собирает сигналы alert_engine напрямую из результатов collector jobs (`scheduler_runs.result_json`). */
export function buildSignalSnapshotFromCollectors(): SignalSnapshot {
export async function buildSignalSnapshotFromCollectors(): Promise<SignalSnapshot> {
const sampledAt = new Date().toISOString()
const resourceRuns = readLatestOkRunSnapshots("uptime_resources", 16) as ResourcesRunSnapshot[]
const restRuns = readLatestOkRunSnapshots("servers_rest_ping", 16) as ServersRestPingRunSnapshot[]
const pingRuns = readLatestOkRunSnapshots("uptime_ping", 24) as PingRunSnapshot[]
const trafficRuns = readLatestOkRunSnapshots("traffic", 8) as TrafficRunSnapshot[]
const greBgpRuns = readLatestOkRunSnapshots("gre_bgp", 8) as GreBgpSnapshotRunSnapshot[]
const resourceRuns = await readLatestOkRunSnapshots("uptime_resources", 16) as ResourcesRunSnapshot[]
const restRuns = await readLatestOkRunSnapshots("servers_rest_ping", 16) as ServersRestPingRunSnapshot[]
const pingRuns = await readLatestOkRunSnapshots("uptime_ping", 24) as PingRunSnapshot[]
const trafficRuns = await readLatestOkRunSnapshots("traffic", 8) as TrafficRunSnapshot[]
const greBgpRuns = await readLatestOkRunSnapshots("gre_bgp", 8) as GreBgpSnapshotRunSnapshot[]
const resourceByKey = collectServerSignalsFromResourceRuns(resourceRuns)
const restByKey = collectServerSignalsFromRestRuns(restRuns)
@@ -2,6 +2,6 @@ import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js"
import type { SignalSnapshot } from "./types.js"
/** Legacy-совместимость: единый источник сигналов — collector snapshots из scheduler_runs. */
export function buildSignalSnapshot(): SignalSnapshot {
return buildSignalSnapshotFromCollectors()
export async function buildSignalSnapshot(): Promise<SignalSnapshot> {
return await buildSignalSnapshotFromCollectors()
}
@@ -11,44 +11,40 @@ const SOURCE_JOBS = [
"gre_bgp",
] as const
export function getLatestSourceFinishedAt(): string | null {
const row = db
export async function getLatestSourceFinishedAt(): Promise<string | null> {
const row = (await db
.select({ finishedAt: schedulerRuns.finishedAt })
.from(schedulerRuns)
.where(inArray(schedulerRuns.jobKey, [...SOURCE_JOBS]))
.orderBy(desc(schedulerRuns.finishedAt))
.limit(1)
.all()[0]
.limit(1))[0]
return row?.finishedAt ?? null
}
export function getSourceWatermark(): string | null {
const row = db.select().from(alertEngineCursor).where(eq(alertEngineCursor.id, 1)).limit(1).all()[0]
export async function getSourceWatermark(): Promise<string | null> {
const row = (await db.select().from(alertEngineCursor).where(eq(alertEngineCursor.id, 1)).limit(1))[0]
return row?.lastSourceFinishedAt ?? null
}
export function updateSourceWatermark(lastSourceFinishedAt: string | null): void {
const existing = db
export async function updateSourceWatermark(lastSourceFinishedAt: string | null): Promise<void> {
const existing = (await db
.select()
.from(alertEngineCursor)
.where(eq(alertEngineCursor.id, 1))
.limit(1)
.all()[0]
.limit(1))[0]
if (existing) {
db.update(alertEngineCursor)
await db.update(alertEngineCursor)
.set({
lastSourceFinishedAt,
updatedAt: new Date().toISOString(),
})
.where(eq(alertEngineCursor.id, 1))
.run()
return
}
db.insert(alertEngineCursor)
await db.insert(alertEngineCursor)
.values({
id: 1,
lastSourceFinishedAt,
updatedAt: new Date().toISOString(),
})
.run()
}
+107 -116
View File
@@ -1,5 +1,5 @@
import { asc, desc, eq, sql } from "drizzle-orm"
import { db, sqliteDatabase } from "../db/index.js"
import { db, dbAll } from "../db/index.js"
import {
alertGroups,
alertHistory,
@@ -55,24 +55,22 @@ export type ApiAlertHistoryEntry = {
source: "db" | "live"
}
function ensureTelegramRow() {
let row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0]
async function ensureTelegramRow() {
let row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
if (!row) {
db.insert(alertTelegramSettings).values({ id: 1 }).run()
row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0]
await db.insert(alertTelegramSettings).values({ id: 1 })
row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
}
return row!
}
function lastFiredByRuleId(): Map<string, string> {
const rows = sqliteDatabase
.prepare(
`SELECT rule_id AS ruleId, MAX(fired_at) AS mx
FROM alert_history
WHERE rule_id IS NOT NULL AND rule_id != ''
GROUP BY rule_id`,
)
.all() as { ruleId: string; mx: string }[]
async function lastFiredByRuleId(): Promise<Map<string, string>> {
const rows = await dbAll<{ ruleId: string; mx: string }>(
`SELECT rule_id AS "ruleId", MAX(fired_at) AS mx
FROM alert_history
WHERE rule_id IS NOT NULL AND rule_id != ''
GROUP BY rule_id`,
)
const m = new Map<string, string>()
for (const r of rows) {
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx)
@@ -80,12 +78,11 @@ function lastFiredByRuleId(): Map<string, string> {
return m
}
function targetsByRuleId(): Map<string, string[]> {
const rows = db
async function targetsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
.select()
.from(alertRuleTargets)
.orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id))
.all()
const m = new Map<string, string[]>()
for (const t of rows) {
const rid = t.ruleId
@@ -96,12 +93,11 @@ function targetsByRuleId(): Map<string, string[]> {
return m
}
function conditionsByRuleId(): Map<string, string[]> {
const rows = db
async function conditionsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
.select()
.from(alertRuleConditions)
.orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id))
.all()
const m = new Map<string, string[]>()
for (const t of rows) {
const rid = t.ruleId
@@ -112,26 +108,25 @@ function conditionsByRuleId(): Map<string, string[]> {
return m
}
export function listAlertGroups(): ApiAlertGroup[] {
return db
export async function listAlertGroups(): Promise<ApiAlertGroup[]> {
const rows = await db
.select()
.from(alertGroups)
.orderBy(asc(alertGroups.name))
.all()
.map((g) => ({
id: g.id,
name: g.name,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
}))
return rows.map((g) => ({
id: g.id,
name: g.name,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
}))
}
export function listAlertRules(): ApiAlertRule[] {
const lf = lastFiredByRuleId()
const tgtMap = targetsByRuleId()
const condMap = conditionsByRuleId()
const rows = db.select().from(alertRules).all()
export async function listAlertRules(): Promise<ApiAlertRule[]> {
const lf = await lastFiredByRuleId()
const tgtMap = await targetsByRuleId()
const condMap = await conditionsByRuleId()
const rows = await db.select().from(alertRules)
return rows.map((r) => {
const targets = tgtMap.get(r.id)
const ts = targets?.length ? targets : [r.target]
@@ -207,23 +202,23 @@ export type ReplaceAlertGroupInput = {
}
/** Атомарно заменяет группы, правила и строки targets. */
export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now = sql`(datetime('now'))`
export async function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now = sql`now()`
const { groups, rules } = payload
db.transaction((tx) => {
tx.delete(alertRuleConditions).run()
tx.delete(alertRuleTargets).run()
tx.delete(alertRules).run()
tx.delete(alertGroups).run()
await db.transaction(async (tx) => {
await tx.delete(alertRuleConditions)
await tx.delete(alertRuleTargets)
await tx.delete(alertRules)
await tx.delete(alertGroups)
for (const g of groups) {
tx.insert(alertGroups).values({
await tx.insert(alertGroups).values({
id: g.id,
name: g.name.trim() || g.id,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
updatedAt: now,
}).run()
})
}
for (const r of rules) {
const rawTargets =
@@ -250,7 +245,7 @@ export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[];
r.recoveryStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
: null
tx.insert(alertRules).values({
await tx.insert(alertRules).values({
id: r.id,
name: r.name,
type: r.type,
@@ -265,39 +260,39 @@ export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[];
chatId: r.chatId ?? "",
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
updatedAt: now,
}).run()
rawTargets.forEach((t, i) => {
tx.insert(alertRuleTargets).values({
})
for (const [i, t] of rawTargets.entries()) {
await tx.insert(alertRuleTargets).values({
id: `rt-${r.id}-${i}`,
ruleId: r.id,
target: t,
sortIndex: i,
}).run()
})
rawConds.forEach((c, i) => {
tx.insert(alertRuleConditions).values({
})
}
for (const [i, c] of rawConds.entries()) {
await tx.insert(alertRuleConditions).values({
id: `rc-${r.id}-${i}`,
ruleId: r.id,
conditionLine: c,
sortIndex: i,
}).run()
})
})
}
}
})
}
/** Совместимость: только правила, группы не трогаем. */
export function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
const curGroups = listAlertGroups()
replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
export async function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
const curGroups = await listAlertGroups()
await replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
}
export function getTelegramSettingsRow() {
return ensureTelegramRow()
export async function getTelegramSettingsRow() {
return await ensureTelegramRow()
}
export function getTelegramPublic() {
const row = ensureTelegramRow()
export async function getTelegramPublic() {
const row = await ensureTelegramRow()
const tid = row.messageThreadId
const messageThreadId =
typeof tid === "number" && Number.isFinite(tid) && Math.floor(tid) >= 1 ? Math.floor(tid) : null
@@ -308,13 +303,13 @@ export function getTelegramPublic() {
}
}
export function updateTelegramSettings(patch: {
export async function updateTelegramSettings(patch: {
token?: string | null
chatId?: string | undefined
/** undefined — не менять; null — сбросить тему */
messageThreadId?: number | null
}) {
const cur = ensureTelegramRow()
const cur = await ensureTelegramRow()
let nextToken = cur.botToken
let nextChat = cur.chatId ?? ""
let nextThread: number | null | undefined = undefined
@@ -335,27 +330,26 @@ export function updateTelegramSettings(patch: {
const base = {
botToken: nextToken,
chatId: nextChat,
updatedAt: sql`(datetime('now'))`,
updatedAt: sql`now()`,
}
if (nextThread !== undefined) {
db.update(alertTelegramSettings)
await db.update(alertTelegramSettings)
.set({ ...base, messageThreadId: nextThread })
.where(eq(alertTelegramSettings.id, 1))
.run()
} else {
db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1)).run()
await db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1))
}
return getTelegramPublic()
return await getTelegramPublic()
}
/** Токен только для внутреннего вызова Telegram API (не отдаётся клиенту). */
export function getTelegramBotToken(): string {
return ensureTelegramRow().botToken?.trim() ?? ""
export async function getTelegramBotToken(): Promise<string> {
return (await ensureTelegramRow()).botToken?.trim() ?? ""
}
/** Для `sendMessage`: только если в БД задана валидная тема. */
export function getTelegramMessageThreadIdForApi(): number | undefined {
const { messageThreadId } = getTelegramPublic()
export async function getTelegramMessageThreadIdForApi(): Promise<number | undefined> {
const { messageThreadId } = await getTelegramPublic()
return messageThreadId ?? undefined
}
@@ -390,9 +384,9 @@ export async function sendTelegramAlertMessage(opts: {
/** Переопределение темы; undefined — как в настройках */
messageThreadId?: number | null
}): Promise<{ ok: true } | { ok: false; error: string }> {
const token = (opts.token?.trim() || getTelegramBotToken()).trim()
const token = (opts.token?.trim() || await getTelegramBotToken()).trim()
if (!token) return { ok: false, error: "Не задан Bot Token" }
const pub = getTelegramPublic()
const pub = await getTelegramPublic()
const chatId = (opts.chatId?.trim() || pub.chatId || "").trim()
if (!chatId) return { ok: false, error: "Не задан Chat ID" }
const threadId =
@@ -400,7 +394,7 @@ export async function sendTelegramAlertMessage(opts: {
? opts.messageThreadId >= 1
? Math.floor(opts.messageThreadId)
: undefined
: getTelegramMessageThreadIdForApi()
: await getTelegramMessageThreadIdForApi()
const url = `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`
try {
const payload: { chat_id: string; text: string; message_thread_id?: number } = {
@@ -423,7 +417,7 @@ export async function sendTelegramAlertMessage(opts: {
}
}
export function appendAlertHistory(entry: {
export async function appendAlertHistory(entry: {
id: string
ruleId?: string | null
groupId?: string | null
@@ -434,7 +428,7 @@ export function appendAlertHistory(entry: {
firedAt?: string
}) {
const firedAt = entry.firedAt ?? new Date().toISOString()
db.insert(alertHistory)
await db.insert(alertHistory)
.values({
id: entry.id,
ruleId: entry.ruleId ?? null,
@@ -445,7 +439,6 @@ export function appendAlertHistory(entry: {
sentOk: entry.sentOk,
firedAt,
})
.run()
}
interface LiveResRow {
@@ -462,38 +455,38 @@ interface LiveProbeRow {
lossPct: number | null
}
function loadLiveResourceIssues(): LiveResRow[] {
return sqliteDatabase
.prepare(
`SELECT s.name AS serverName, urs.sampled_at AS sampledAt
FROM uptime_resource_samples urs
INNER JOIN (
SELECT server_id, MAX(id) AS mid FROM uptime_resource_samples GROUP BY server_id
) latest ON latest.mid = urs.id
INNER JOIN servers s ON s.id = urs.server_id
WHERE urs.status = 'offline'`,
)
.all() as LiveResRow[]
async function loadLiveResourceIssues(): Promise<LiveResRow[]> {
return dbAll<LiveResRow>(
`SELECT s.name AS "serverName", urs.sampled_at AS "sampledAt"
FROM uptime_resource_samples urs
INNER JOIN (
SELECT DISTINCT ON (server_id) server_id, sampled_at, status
FROM uptime_resource_samples
ORDER BY server_id, sampled_at DESC
) latest ON latest.server_id = urs.server_id AND latest.sampled_at = urs.sampled_at
INNER JOIN servers s ON s.id = urs.server_id
WHERE urs.status = 'offline'`,
)
}
function loadLiveProbeIssues(): LiveProbeRow[] {
return sqliteDatabase
.prepare(
`SELECT p.name AS probeName, p.target AS target, ups.sampled_at AS sampledAt,
ups.status AS status, ups.rtt_ms AS rttMs, ups.loss_pct AS lossPct
FROM uptime_probe_samples ups
INNER JOIN (
SELECT probe_id, MAX(id) AS mid FROM uptime_probe_samples GROUP BY probe_id
) latest ON latest.mid = ups.id
INNER JOIN uptime_probes p ON p.id = ups.probe_id
WHERE ups.status IN ('down', 'warn')`,
)
.all() as LiveProbeRow[]
async function loadLiveProbeIssues(): Promise<LiveProbeRow[]> {
return dbAll<LiveProbeRow>(
`SELECT p.name AS "probeName", p.target AS target, ups.sampled_at AS "sampledAt",
ups.status AS status, ups.rtt_ms AS "rttMs", ups.loss_pct AS "lossPct"
FROM uptime_probe_samples ups
INNER JOIN (
SELECT DISTINCT ON (probe_id) probe_id, sampled_at, status, rtt_ms, loss_pct
FROM uptime_probe_samples
ORDER BY probe_id, sampled_at DESC
) latest ON latest.probe_id = ups.probe_id AND latest.sampled_at = ups.sampled_at
INNER JOIN uptime_probes p ON p.id = ups.probe_id
WHERE ups.status IN ('down', 'warn')`,
)
}
function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
async function toLiveHistoryEntries(): Promise<ApiAlertHistoryEntry[]> {
const out: ApiAlertHistoryEntry[] = []
for (const r of loadLiveResourceIssues()) {
for (const r of await loadLiveResourceIssues()) {
const safeId = `live-res-${r.serverName}-${r.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
out.push({
id: safeId,
@@ -505,7 +498,7 @@ function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
source: "live",
})
}
for (const p of loadLiveProbeIssues()) {
for (const p of await loadLiveProbeIssues()) {
const sev = p.status === "down" ? "critical" : "warning"
const loss = p.lossPct != null ? `${p.lossPct}%` : "—"
const rtt = p.rttMs != null ? `${p.rttMs} мс` : "—"
@@ -523,13 +516,12 @@ function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
return out
}
export function listPersistedHistory(limit: number): ApiAlertHistoryEntry[] {
const rows = db
export async function listPersistedHistory(limit: number): Promise<ApiAlertHistoryEntry[]> {
const rows = await db
.select()
.from(alertHistory)
.orderBy(desc(alertHistory.firedAt))
.limit(limit)
.all()
return rows.map((h) => ({
id: h.id,
ruleName: h.ruleName,
@@ -541,9 +533,9 @@ export function listPersistedHistory(limit: number): ApiAlertHistoryEntry[] {
}))
}
export function listMergedHistory(persistLimit = 40, liveCap = 25): ApiAlertHistoryEntry[] {
const persisted = listPersistedHistory(persistLimit)
const live = toLiveHistoryEntries().slice(0, liveCap)
export async function listMergedHistory(persistLimit = 40, liveCap = 25): Promise<ApiAlertHistoryEntry[]> {
const persisted = await listPersistedHistory(persistLimit)
const live = (await toLiveHistoryEntries()).slice(0, liveCap)
const merged = [...persisted, ...live].sort((a, b) => (a.firedAt < b.firedAt ? 1 : a.firedAt > b.firedAt ? -1 : 0))
const seen = new Set<string>()
const dedup: ApiAlertHistoryEntry[] = []
@@ -556,8 +548,8 @@ export function listMergedHistory(persistLimit = 40, liveCap = 25): ApiAlertHist
return dedup
}
export function getAlertsMeta() {
const srvRows = db
export async function getAlertsMeta() {
const srvRows = await db
.select({
name: servers.name,
host: servers.host,
@@ -566,7 +558,6 @@ export function getAlertsMeta() {
type: servers.type,
})
.from(servers)
.all()
const labels = srvRows.map((s) => (s.name?.trim() ? s.name.trim() : s.host))
const serversDetail = srvRows.map((r) => ({
name: r.name?.trim() || "",
@@ -575,7 +566,7 @@ export function getAlertsMeta() {
country: r.country?.trim() || "",
type: (r.type ?? "home-router") as string,
}))
const probes = db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes).all()
const probes = await db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes)
const probeTargets = probes.map((p) => `${p.name}${p.target}`)
const defaults = ["8.8.8.8", "1.1.1.1"]
const rttLossTargets: string[] = []
@@ -18,7 +18,7 @@ export function getBackupSchedulerCollectorState(): { running: boolean } {
export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot> {
const sampledAt = new Date().toISOString()
const settings = getBackupScheduleSettings()
const settings = await getBackupScheduleSettings()
const snapshot: BackupsRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "backups",
@@ -49,7 +49,7 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
if (settings.format !== "rsc") {
snapshot.skipped = true
snapshot.errors = ["Формат backup пока не поддерживается, используйте rsc"]
touchBackupScheduleRunMeta({
await touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: 0,
lastError: snapshot.errors[0],
@@ -59,9 +59,9 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
collecting = true
const started = Date.now()
const serverIds = resolveBackupServerIds(settings)
const serverIds = await resolveBackupServerIds(settings)
appendEvent({
await appendEvent({
level: "info",
eventType: "backups.job.started",
sourceModule: "backups",
@@ -88,13 +88,13 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount)
}
touchBackupScheduleRunMeta({
await touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: snapshot.errors?.length ? snapshot.errors.join("; ") : null,
})
appendEvent({
await appendEvent({
level: snapshot.failures > 0 ? "warning" : "info",
eventType: "backups.job.done",
sourceModule: "backups",
@@ -115,12 +115,12 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
const message = e instanceof Error ? e.message : String(e)
snapshot.fatalError = message
snapshot.errors?.push(message)
touchBackupScheduleRunMeta({
await touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: message,
})
appendEvent({
await appendEvent({
level: "critical",
eventType: "backups.job.failed",
sourceModule: "backups",
+38 -43
View File
@@ -4,6 +4,7 @@ import path from "node:path"
import { desc, eq } from "drizzle-orm"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { listServersRead } from "../modules/servers/service/servers-service.js"
@@ -14,7 +15,7 @@ const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
export type BackupMeta = {
id: string
serverId: string
serverId: number | null
serverName: string
filename: string
sizeBytes: number
@@ -40,17 +41,18 @@ export async function ensureBackupStorage(): Promise<void> {
await mkdir(BACKUPS_DIR, { recursive: true })
}
export function listBackups(): BackupMeta[] {
return db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt)).all().map(rowToMeta)
export async function listBackups(): Promise<BackupMeta[]> {
const rows = await db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt))
return rows.map(rowToMeta)
}
export function getBackupById(id: string): BackupMeta | null {
const row = db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1).all()[0]
export async function getBackupById(id: string): Promise<BackupMeta | null> {
const row = (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
return row ? rowToMeta(row) : null
}
export function insertBackup(meta: BackupMeta): void {
db.insert(backupEntries).values({
export async function insertBackup(meta: BackupMeta): Promise<void> {
await db.insert(backupEntries).values({
id: meta.id,
serverId: meta.serverId,
serverName: meta.serverName,
@@ -59,13 +61,13 @@ export function insertBackup(meta: BackupMeta): void {
kind: meta.kind,
notes: meta.notes ?? null,
createdAt: meta.createdAt,
}).run()
})
}
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
const hit = getBackupById(id)
const hit = await getBackupById(id)
if (!hit) return null
db.delete(backupEntries).where(eq(backupEntries.id, id)).run()
await db.delete(backupEntries).where(eq(backupEntries.id, id))
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
return hit
}
@@ -75,18 +77,12 @@ function fmtTs(d = new Date()): string {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
function parseServerIds(raw: string): string[] {
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.map(String).filter(Boolean)
} catch {
return []
}
function parseServerIds(raw: unknown): string[] {
return parseJsonArray(raw).map(String).filter(Boolean)
}
function getBackupScheduleSettingsRow() {
return db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]
async function getBackupScheduleSettingsRow() {
return (await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]
?? {
id: SETTINGS_ID,
enabled: true,
@@ -97,7 +93,7 @@ function getBackupScheduleSettingsRow() {
monthDay: 1,
keepCount: 7,
format: "rsc" as const,
serverIdsJson: "[]",
serverIdsJson: [],
lastRunAt: null,
lastDurationMs: null,
lastError: null,
@@ -105,8 +101,8 @@ function getBackupScheduleSettingsRow() {
}
}
export function getBackupScheduleSettings(): BackupScheduleSettingsDto {
const row = getBackupScheduleSettingsRow()
export async function getBackupScheduleSettings(): Promise<BackupScheduleSettingsDto> {
const row = await getBackupScheduleSettingsRow()
return {
enabled: row.enabled,
frequency: row.frequency,
@@ -124,7 +120,7 @@ export function getBackupScheduleSettings(): BackupScheduleSettingsDto {
}
}
export function updateBackupScheduleSettings(patch: Partial<{
export async function updateBackupScheduleSettings(patch: Partial<{
enabled: boolean
frequency: "daily" | "weekly" | "monthly"
hour: number
@@ -135,7 +131,7 @@ export function updateBackupScheduleSettings(patch: Partial<{
format: "rsc" | "backup"
serverIds: string[]
}>) {
const prev = getBackupScheduleSettingsRow()
const prev = await getBackupScheduleSettingsRow()
const now = new Date().toISOString()
const next = {
enabled: patch.enabled ?? prev.enabled,
@@ -146,33 +142,33 @@ export function updateBackupScheduleSettings(patch: Partial<{
monthDay: patch.monthDay ?? prev.monthDay,
keepCount: patch.keepCount ?? prev.keepCount,
format: patch.format ?? prev.format,
serverIdsJson: patch.serverIds ? JSON.stringify(patch.serverIds) : prev.serverIdsJson,
serverIdsJson: patch.serverIds ?? prev.serverIdsJson,
updatedAt: now,
}
if (db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
if ((await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]) {
await db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID))
} else {
db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next }).run()
await db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next })
}
return getBackupScheduleSettings()
return await getBackupScheduleSettings()
}
export function touchBackupScheduleRunMeta(patch: {
export async function touchBackupScheduleRunMeta(patch: {
lastRunAt?: string
lastDurationMs?: number
lastError?: string | null
}) {
const prev = getBackupScheduleSettingsRow()
db.update(backupScheduleSettings).set({
const prev = await getBackupScheduleSettingsRow()
await db.update(backupScheduleSettings).set({
lastRunAt: patch.lastRunAt ?? prev.lastRunAt,
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
updatedAt: new Date().toISOString(),
}).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
}).where(eq(backupScheduleSettings.id, SETTINGS_ID))
}
export function resolveBackupServerIds(settings: BackupScheduleSettingsDto): string[] {
const enabled = new Set(listServersRead().map((s) => String(s.id)))
export async function resolveBackupServerIds(settings: BackupScheduleSettingsDto): Promise<string[]> {
const enabled = new Set((await listServersRead()).map((s) => String(s.id)))
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
return [...new Set(requested)].filter((id) => enabled.has(id))
}
@@ -227,7 +223,7 @@ export async function runBackupForServer(
if (!Number.isFinite(serverIdNum)) {
throw new Error("Невалидный id сервера")
}
const row = getServerRowById(serverIdNum)
const row = await getServerRowById(serverIdNum)
if (!row) {
throw new Error("Сервер не найден")
}
@@ -242,7 +238,7 @@ export async function runBackupForServer(
const st = await stat(filePath)
const meta: BackupMeta = {
id: randomUUID(),
serverId: String(row.id),
serverId: row.id,
serverName: row.name,
filename,
sizeBytes: st.size,
@@ -250,19 +246,18 @@ export async function runBackupForServer(
kind,
notes,
}
insertBackup(meta)
await insertBackup(meta)
return meta
}
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
const rows = db.select().from(backupEntries)
.where(eq(backupEntries.serverId, serverId))
const rows = await db.select().from(backupEntries)
.where(eq(backupEntries.serverId, Number(serverId)))
.orderBy(desc(backupEntries.createdAt))
.all()
if (rows.length <= keepCount) return 0
const toDelete = rows.slice(keepCount)
for (const hit of toDelete) {
db.delete(backupEntries).where(eq(backupEntries.id, hit.id)).run()
await db.delete(backupEntries).where(eq(backupEntries.id, hit.id))
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
}
return toDelete.length
+1 -1
View File
@@ -14,7 +14,7 @@ export function bgpPeerAlertKey(s: BgpSessionRead): string {
/** Агрегат BGP-сессий с включённых серверов — как `/api/bgp/sessions`. */
export async function fetchBgpSessionsForAlerts(): Promise<BgpSessionRead[]> {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {

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