Compare commits

..
26 Commits
Author SHA1 Message Date
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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
DenozordecandCursor 5e0c16e808 fix(ui): выровнять confirms и токены под ReUI PRO
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m31s
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 10s
Добавить ui-design-contract, AlertDialog вместо hand-roll, CodeExportSheet для preview фильтров, ReUI Badge и gap вместо space-y.

Co-authored-by: Cursor <[email protected]>
2026-09-06 00:12:57 +07:00
DenozordecandCursor 66509b26bd fix(ui): выровнять экспорт WireGuard под ReUI Frame
Убрать дубль копирования, вынести CodeExportSheet и семантические токены.
Открывать вкладку Peer при экспорте пира. Мигрировать VXLAN/Firewall/Containers/GRE.

Co-authored-by: Cursor <[email protected]>
2026-09-06 00:12:50 +07:00
Denozordec 15ad53af1f feat(wireguard): implement WireGuard interface management and permissions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 53s
Docker images / publish-release (push) Successful in 10s
Added comprehensive support for managing WireGuard interfaces, including CRUD operations and peer management. Updated permissions to include access control for WireGuard routes. Enhanced the UI components to display and interact with WireGuard configurations, improving user experience and functionality. Introduced new tests for WireGuard-related functionalities to ensure reliability.
2026-09-05 02:10:55 +07:00
Denozordec 883842636b refactor(api): streamline API requests with requestJson and requestBlob functions
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m24s
Docker images / frontend-image (push) Successful in 2m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 7s
Replaced direct fetch calls with requestJson and requestBlob utility functions across multiple components for improved consistency and error handling. This change enhances the maintainability of the codebase by centralizing API request logic and ensuring uniform handling of authentication and response parsing.
2026-09-05 01:42:44 +07:00
Denozordec b9f430de16 refactor(acme-cloudflare): enhance upsertARecord and syncCertificateDomainRecords functions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 2m21s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Updated the upsertARecord function to return status messages ("updated", "created", "skipped_cname") instead of void, improving clarity on record handling. Modified syncCertificateDomainRecords to collect and return skipped CNAME records, enhancing error handling and feedback during DNS operations.
2026-09-05 01:29:58 +07:00
Denozordec 25e040a5dd fix(settings): improve error handling and success notifications in EvoBGP settings
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m26s
Docker images / frontend-image (push) Successful in 2m26s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Enhanced the error handling in the settings page by introducing a dedicated error message function. Added success and error toast notifications for better user feedback during settings save operations. Updated API key normalization to ensure consistent handling across the application.
2026-09-05 00:07:58 +07:00
Denozordec f2df990746 chore(docker): enhance Dockerfile for backend to manage nested dependencies
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m23s
Docker images / frontend-image (push) Successful in 2m12s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Successful in 9s
Updated the Dockerfile to create a directory for nested workspace dependencies and adjusted the copy commands to ensure proper handling of node_modules during the build process.
2026-09-04 23:48:03 +07:00
Denozordec 77bc174e43 chore(deps): update package-lock.json and backend dependencies, adjust Dockerfile for leaner backend image
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m46s
Docker images / frontend-image (push) Successful in 2m35s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 6s
Removed unnecessary frontend dependencies from backend Docker image and updated package-lock.json to include new dev dependencies. Adjusted backend Dockerfile to streamline the build process and ensure proper workspace configuration.
2026-09-04 23:37:23 +07:00
DenozordecandCursor 0e9349e508 feat(auth): интегрировать SSO auth-portal
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m52s
Docker images / frontend-image (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
JWT на backend, handoff/callback на UI, RBAC mm:*, AUTH_* в compose.

Co-authored-by: Cursor <[email protected]>
2026-09-04 20:38:28 +07:00
DenozordecandCursor 0208aa4d7c chore(deploy): добавить Traefik-стеки и общий compose с CDNManager
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 3m23s
Docker images / frontend-image (push) Successful in 2m1s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 6s
Единый стек CDN+MM, вариант рядом с CDN Traefik и CLI-скрипт запуска.

Co-authored-by: Cursor <[email protected]>
2026-09-04 19:07:49 +07:00
Denozordec 42a2e18047 docs: обновить раздел о прод-развёртывании Docker и изменить ссылки на репозиторий
Docker images / prepare-release (push) Successful in 23s
Docker images / backend-image (push) Failing after 1m30s
Docker images / frontend-image (push) Successful in 3m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 50s
Docker images / publish-release (push) Skipped
2026-09-04 18:54:26 +07:00
158 changed files with 21544 additions and 3516 deletions
+3 -3
View File
@@ -12,9 +12,9 @@
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
"WebFetch(domain:git.shts.su)",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"WebFetch(domain:git.shx.one)",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"Bash(node -e ' *)",
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
+12 -1
View File
@@ -61,7 +61,16 @@ jobs:
STAGING=".ci/docker/backend"
rm -rf "$STAGING"
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
cp package.json package-lock.json "$STAGING/"
cp package-lock.json "$STAGING/"
node <<'NODE'
const fs = require("node:fs")
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
pkg.workspaces = ["packages/*", "backend"]
pkg.dependencies = {}
pkg.devDependencies = {}
delete pkg.scripts
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
NODE
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
cp -R packages/contracts/src "$STAGING/packages/contracts/"
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
@@ -70,6 +79,7 @@ jobs:
cp -R backend/drizzle "$STAGING/backend/"
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -177,6 +187,7 @@ jobs:
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
tags: |
+2
View File
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
ARG NEXT_PUBLIC_APP_VERSION=dev
ARG NEXT_PUBLIC_RELEASE_URL=
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
COPY packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app
+57 -1
View File
@@ -135,6 +135,7 @@ sequenceDiagram
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
### Запуск
@@ -219,7 +220,62 @@ npm --prefix backend run db:studio
## Прод-развёртывание Docker
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория).
Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
### CDN Manager + MikrotikManager (один Traefik)
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
| Файл | Назначение |
|------|------------|
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
```bash
mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
cp deploy/env.cdn-mm.example /opt/cdn-mm/.env
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
docker login git.shx.one
cd /opt/cdn-mm && docker compose pull && docker compose up -d
curl -fsS https://cdn.shnt.top/health
curl -fsS https://mm.shnt.top/health
```
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
| Способ | Файл |
|--------|------|
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
```bash
# Compose (рекомендуется)
mkdir -p /opt/mmapp/{data,state,updater}
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
cd /opt/mmapp && docker compose pull && docker compose up -d
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
chmod +x /tmp/run-beside-cdn-traefik.sh
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
```
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
### Прод-контейнеры
+36 -25
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"
@@ -28,6 +27,7 @@ import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { requestBlob } from "@/shared/api/http-client"
import { toast } from "sonner"
import {
Stepper,
@@ -276,8 +276,7 @@ export default function BackupsPage() {
}
async function handleDownload(id: string, fallbackFilename: string) {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
if (!res.ok) throw new Error("Не удалось скачать файл")
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
@@ -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">
+71 -38
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,9 +21,10 @@ 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"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -462,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 */}
@@ -621,11 +640,7 @@ export default function BgpPage() {
if (cancelled) return
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/bgp/sessions`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<BackendBgpSession[]>
})
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
.then(data => {
if (cancelled) return
setLiveSessions(data.map(backendToFrontend))
@@ -725,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 ── */}
+51 -71
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"
@@ -17,13 +17,14 @@ import {
import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -104,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string {
function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт Container</SheetTitle>
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт Container"
description="RouterOS 7.4+ · /container · /interface/veth"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `${container?.name ?? "container"}.rsc`,
code,
},
]}
/>
)
}
@@ -319,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">
+63 -51
View File
@@ -4,10 +4,9 @@ import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge"
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,
@@ -104,7 +104,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{t.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
) : null}
@@ -126,7 +126,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_resources") {
const u = snap as ResourcesRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{u.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
) : null}
@@ -148,7 +148,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "servers_rest_ping") {
const s = snap as ServersRestPingRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{s.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
) : null}
@@ -171,7 +171,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_ping") {
const p = snap as PingRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{p.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
) : null}
@@ -196,7 +196,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_speed") {
const s = snap as SpeedScheduledRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> по очереди для каждой включённой пробы
</p>
@@ -209,7 +209,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "gre_bgp") {
const g = snap as GreBgpSnapshotRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
) : null}
@@ -235,7 +235,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
</dl>
{g.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
@@ -249,7 +249,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "internet_path") {
const p = snap as InternetPathRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Снимок internet-path на{" "}
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
@@ -276,7 +276,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null}
@@ -299,7 +299,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
</dl>
{c.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
@@ -311,7 +311,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Слот расписания</dt>
@@ -331,7 +331,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
</dl>
{b.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
@@ -343,7 +343,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Снимок на{" "}
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
@@ -370,7 +370,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
{a.errors?.length ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="space-y-1 text-xs">
<AlertDescription className="flex flex-col gap-1 text-xs">
{a.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
@@ -380,7 +380,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</Alert>
) : null}
{a.ruleDiag && a.ruleDiag.length > 0 ? (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
<DataPageCard className="border-0 shadow-none bg-transparent">
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
@@ -398,39 +398,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
return (
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-4 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
<dd className="font-mono text-xs">{r.jobKey}</dd>
</div>
<div className="sm:col-span-2 space-y-1">
<div className="sm:col-span-2 flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
<dd>
<span className="font-medium">{jobTitle}</span>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
<dd className="tabular-nums">
<span className="font-mono">{r.durationMs}</span> мс
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
<dd>
<Badge
@@ -447,7 +447,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
</div>
</dl>
{r.error ? (
<div className="space-y-1.5">
<div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
<pre
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
@@ -461,7 +461,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
<Separator />
<div className="space-y-2">
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
{snapshot ? (
<SnapshotTables snap={snapshot} />
@@ -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">
@@ -1090,7 +1100,7 @@ export default function DataCollectionPage() {
</div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
<Separator />
<div className="space-y-3 px-5 py-4">
<div className="flex flex-col gap-3 px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
@@ -1142,7 +1152,7 @@ export default function DataCollectionPage() {
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить настройки планировщика
</Button>
<div className="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60">
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
<p>
Трафик последний сбор:{" "}
{trafficCollector?.lastCollectedAt
@@ -1201,6 +1211,8 @@ export default function DataCollectionPage() {
</div>
</DataPageCard>
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
<OpsPanel
title="Журнал прогонов"
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
+153 -226
View File
@@ -35,6 +35,9 @@ import {
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 ────────────────────────────────────────────────────────────────────
@@ -754,13 +757,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
tunnelsList: GreTunnel[]
recRoutesByServer: Record<string, RecursiveRouteLite[]>
}) {
const [copied, setCopied] = useState(false)
const singleRuleset = useMemo(
() => rulesets.filter(r => r.serverId === serverId),
[rulesets, serverId],
)
const server = serversList.find(s => s.id === serverId)
const server = serversList.find(s => s.id === serverId)
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
const config = useMemo(
@@ -771,93 +772,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
)
const handleCopy = () => {
navigator.clipboard.writeText(config).catch(() => {})
setCopied(true); setTimeout(() => setCopied(false), 2000)
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-2xl mx-4 bg-card rounded-xl border shadow-2xl flex flex-col max-h-[85vh]">
{/* header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0">
<div className="flex items-center gap-2.5">
<FileCodeIcon className="size-4 text-muted-foreground" />
<span className="text-sm font-semibold">RouterOS config</span>
{server && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
<Flag code={server.country} size={11} />
<span className="font-mono">{server.name}</span>
<span>· {totalRules} правил</span>
</span>
)}
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose}><XIcon className="size-4" /></Button>
</div>
{/* code */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<pre className="text-xs font-mono bg-[#0d1117] rounded-lg p-4 leading-[1.6] whitespace-pre overflow-x-auto">
{config.split("\n").map((line, i) => {
const trimmed = line.trimStart()
const cls =
// section dividers
trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
? "text-[#444c56]" :
// inline comments inside rule body
trimmed.startsWith("# ")
? "text-[#8b949e]" :
// RouterOS scripting keywords
trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
? "text-[#d2a8ff]" :
// :if identity branch / closing brace
trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
? "text-[#ff7b72] font-semibold" :
// filter rule add command
trimmed.startsWith("/routing filter rule")
? "text-[#79c0ff]" :
// rule body: if/else if branches
trimmed.startsWith("if (") || trimmed.startsWith("} else if")
? "text-[#ff7b72]" :
// rule body: blackhole action
trimmed.startsWith("set type blackhole")
? "text-[#ff7b72] font-semibold" :
// rule body: set actions
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
? "text-[#a5d6ff]" :
// rule body: accept / rule close
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
? "text-[#79c0ff]" :
// named params
trimmed.match(/^(chain|comment|bgp-communities)=/)
? "text-[#a5d6ff]" :
"text-[#c9d1d9]"
return <span key={i} className={cn("block", cls)}>{line || " "}</span>
})}
</pre>
</div>
{/* footer */}
<div className="px-5 py-4 border-t bg-muted/30 shrink-0 space-y-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-muted-foreground">
<p>1. Скопируйте скрипт в буфер обмена</p>
<p>3. Вставьте в терминал и нажмите Enter</p>
<p>2. Подключитесь к любому MikroTik (SSH / Winbox)</p>
<p>4. Скрипт сам определит свои правила по identity</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Закрыть</Button>
<Button onClick={handleCopy} className="flex-1">
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано!" : "Скопировать"}
</Button>
</div>
</div>
</div>
</div>
<CodeExportSheet
open={open}
onClose={onClose}
title="RouterOS config"
description={
server
? `${server.name} · ${totalRules} правил`
: "Экспорт правил фильтрации"
}
formats={[
{
id: "rsc",
label: "RouterOS",
filename: `filters-${server?.name ?? "server"}.rsc`,
code: config,
},
]}
/>
)
}
@@ -1470,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],
@@ -1635,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 (моки) */}
@@ -1886,7 +1813,7 @@ export default function FiltersPage() {
</DataPageCard>
</div>
</div>
</ServerRailLayout>
<RuleSheet
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
@@ -1927,6 +1854,6 @@ export default function FiltersPage() {
recRoutesByServer={recRoutesByServer}
ensureRecursiveFor={ensureRecursiveRoutes}
/>
</div>
</>
)
}
File diff suppressed because it is too large Load Diff
+155 -149
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,14 +26,16 @@ 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,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
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 ─────────────────────────────────────────────────────────────
@@ -240,11 +242,11 @@ 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)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
const [copied, setCopied] = useState(false)
const [tForm, setTForm] = useState(defaultTunnelForm)
const [pForm, setPForm] = useState(defaultPoolForm)
@@ -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,60 +375,68 @@ 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 },
]
function handleCopy(code: string) {
navigator.clipboard.writeText(code).then(() => {
setCopied(true)
toast.success("Команды скопированы")
setTimeout(() => setCopied(false), 2000)
})
}
const greExportCode = useMemo(
() => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
[codePreviewTunnel, serverById],
)
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 */}
@@ -424,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">
@@ -548,85 +589,50 @@ export default function GrePage() {
</div>
</OpsPanel>
</div>
</div>
</ServerRailLayout>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
{codePreviewTunnel && (() => {
const code = generateRosCommands(codePreviewTunnel, serverById)
return (
<>
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
</div>
<Button
variant="outline" size="sm"
className="shrink-0 gap-1.5"
onClick={() => handleCopy(code)}
>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
: <><CopyIcon className="size-3.5" /> Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{/* meta strip */}
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
<span className="text-muted-foreground">·</span>
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} {codePreviewTunnel.remoteAddress}</span>
{codePreviewTunnel.ipsec && (
<>
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
</>
)}
</div>
{/* code block */}
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isSection = isComment && line.includes("──")
const isKey = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isSection ? "text-muted-foreground/60"
: isComment ? "text-muted-foreground"
: isKey ? "text-sky-400/90"
: "text-foreground"
}>
{line}
{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать команды"}
</Button>
</SheetFooter>
</>
)
})()}
</SheetContent>
</Sheet>
<CodeExportSheet
open={!!codePreviewTunnel}
onClose={() => setCodePreviewTunnel(null)}
title={codePreviewTunnel?.name ?? "GRE"}
description="Команды RouterOS 7.20+ для создания туннеля"
formats={[
{
id: "rsc",
label: "RouterOS",
filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
code: greExportCode,
},
]}
beforeCode={
codePreviewTunnel ? (
<div className="flex flex-wrap gap-3 text-xs shrink-0">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
<span className="text-muted-foreground">·</span>
<span className="font-mono">
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
{" → "}
{codePreviewTunnel.remoteAddress}
</span>
{codePreviewTunnel.ipsec ? (
<>
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-success">
<LockIcon className="size-3" />
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
</span>
</>
) : null}
</div>
) : null
}
/>
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
@@ -839,6 +845,6 @@ export default function GrePage() {
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</>
)
}
+20 -17
View File
@@ -1,5 +1,6 @@
import type { CSSProperties, ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette"
import { ReleaseNotesModal } from "@/components/release-notes-modal"
@@ -11,22 +12,24 @@ const SKIP_TO_CONTENT_CLASS =
export default function MainLayout({ children }: { children: ReactNode }) {
return (
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider
style={{ "--sidebar-width": "240px" } as CSSProperties}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
<AuthGuard>
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider
style={{ "--sidebar-width": "240px" } as CSSProperties}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
</AuthGuard>
)
}
+160 -142
View File
@@ -7,16 +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 { Flag } from "@/components/flag"
import { requestJson } from "@/shared/api/http-client"
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 ────────────────────────────────────────────────────────────────────
@@ -706,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[]> = {}
@@ -733,13 +737,14 @@ function InterfacesTab({
const ra = readStoredRouteOptimizerSettings()
setOptimizing(true)
try {
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pingWeight: ra.pingWeight }),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json() as BackendOspfOptimizeResponse
const data = await requestJson<BackendOspfOptimizeResponse>(
backendUrl,
`/api/servers/${filterServerId}/ospf/optimize`,
{
method: "POST",
body: JSON.stringify({ pingWeight: ra.pingWeight }),
},
)
const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
@@ -919,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]">
@@ -1042,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">
@@ -1094,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"
@@ -1120,8 +1158,7 @@ export default function OspfPage() {
if (cancelled) return
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/ospf/all`)
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
.then(data => {
if (cancelled) return
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
@@ -1217,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
@@ -1228,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 */}
@@ -1345,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
@@ -1383,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
+121 -119
View File
@@ -4,8 +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"
@@ -259,12 +273,14 @@ function Terminal({
if (isLive && server.backendId !== null) {
setExecuting(true)
try {
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: cmd }),
})
const data = await res.json() as { output?: string; error?: string }
const data = await requestJson<{ output?: string; error?: string }>(
backendUrl,
`/api/servers/${server.backendId}/exec`,
{
method: "POST",
body: JSON.stringify({ command: cmd }),
},
)
const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line =>
@@ -427,7 +443,7 @@ interface BackendServer {
}
export default function TerminalPage() {
const { mode, backendUrl } = useDataSource()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live"
// Server list state
@@ -437,14 +453,13 @@ export default function TerminalPage() {
// Load servers from backend when in live mode
useEffect(() => {
if (!isLive) return
if (!isLive || !prefsHydrated) return
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setServersLoading(true)
fetch(`${backendUrl}/api/servers`)
.then(r => r.json() as Promise<BackendServer[]>)
.then(data => {
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
.then((data) => {
if (cancelled) return
setLiveServers(data.map(s => ({
uid: String(s.id),
@@ -462,7 +477,7 @@ export default function TerminalPage() {
.catch(() => { if (!cancelled) setServersLoading(false) })
})
return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey])
}, [isLive, backendUrl, refreshKey, prefsHydrated])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
@@ -490,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
@@ -500,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}
@@ -610,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>
)
}
+50 -78
View File
@@ -4,22 +4,16 @@ 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, CopyIcon, CheckIcon,
CodeXmlIcon, LayersIcon,
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -70,57 +64,23 @@ function generateVxlanRsc(t: VxlanTunnel): string {
function ExportSheet({ open, tunnel, onClose }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт VXLAN</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт VXLAN"
description="RouterOS 7.x · /interface/vxlan + vteps"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
code,
},
]}
/>
)
}
@@ -157,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">
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
"use client"
export default function AccessDeniedPage() {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
<h1 className="text-lg font-semibold">Нет доступа</h1>
<p className="text-muted-foreground max-w-md text-sm">
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
необходимых прав. Обратитесь к администратору auth-portal.
</p>
<a
href="/auth/callback"
className="text-primary text-sm underline-offset-4 hover:underline"
>
Войти снова
</a>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import {
clearPortalHandoffFlag,
clearToken,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getToken,
parseHashToken,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
setToken,
} from "@/lib/auth"
export default function AuthCallbackPage() {
const router = useRouter()
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
useEffect(() => {
let cancelled = false
void (async () => {
await ensureAuthConfig()
if (cancelled) return
const params = new URLSearchParams(window.location.search)
const error = params.get("error")
if (error === "sso_loop" || error === "jwt_rejected") {
redirectToPortalLoginInteractive()
return
}
const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) {
setToken(accessToken)
clearPortalHandoffFlag()
const claims = getClaims()
if (!claims) {
clearToken()
redirectToPortalLoginInteractive()
return
}
if (!claims.apps.includes("mm")) {
setMessage("Нет доступа к приложению")
router.replace("/access-denied")
return
}
try {
const res = await fetch("/api/auth/config", {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (res.status === 401) {
clearToken()
redirectToPortalLoginInteractive()
return
}
} catch {
/* ignore network — proceed */
}
const next = firstAllowedPath()
if (next === "/access-denied") {
router.replace("/access-denied")
return
}
router.replace(next)
return
}
if (getToken() && getClaims()) {
clearPortalHandoffFlag()
if (!getClaims()!.apps.includes("mm")) {
router.replace("/access-denied")
return
}
router.replace(firstAllowedPath())
return
}
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
if (!ok) redirectToPortalLoginInteractive()
})()
return () => {
cancelled = true
}
}, [router])
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
{message}
</div>
)
}
+7
View File
@@ -6,3 +6,10 @@ PORT=8000
# Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000
# Portal SSO (false = open API for local/dev)
AUTH_REQUIRED=false
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
+19 -2
View File
@@ -5,10 +5,23 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
COPY package.json package-lock.json ./
COPY packages/contracts/package.json packages/contracts/
COPY backend/package.json backend/
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
RUN node -e "\
const fs=require('fs');\
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
p.dependencies={};\
p.devDependencies={};\
delete p.scripts;\
p.workspaces=['packages/*','backend'];\
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
"
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
&& npm rebuild better-sqlite3
FROM deps AS build
@@ -18,7 +31,9 @@ COPY packages/contracts packages/contracts
COPY backend backend
RUN npm run build -w @mmapp/contracts \
&& npm run build -w mikrotik-manager-backend \
&& npm prune --omit=dev
&& npm prune --omit=dev \
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
&& mkdir -p backend/node_modules
FROM node:22-bookworm-slim AS runner
WORKDIR /app
@@ -32,6 +47,8 @@ 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
# 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
EXPOSE 8000
CMD ["node", "backend/dist/index.js"]
+10 -2
View File
@@ -10,10 +10,16 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"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: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-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-analytics.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -21,7 +27,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"pino-pretty": "^13.1.3",
"fastify-plugin": "^5.1.0",
"undici": "^8.1.0",
"zod": "^4.4.1"
},
@@ -29,6 +35,8 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"jose": "^6.2.11",
"pino-pretty": "^13.1.3",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
+46 -2
View File
@@ -3,17 +3,61 @@ import { z } from "zod"
config()
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === "") return fallback
return v === "1" || v.toLowerCase() === "true"
}
const isProd = process.env.NODE_ENV === "production"
const envSchema = z.object({
DATABASE_PATH: z.string().default("./mikrotik.db"),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
AUTH_REQUIRED: z.boolean().default(false),
AUTH_JWT_SECRET: z.string().default(""),
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
})
const parsed = envSchema.safeParse(process.env)
const raw = {
DATABASE_PATH: process.env.DATABASE_PATH,
PORT: process.env.PORT,
CORS_ORIGIN: process.env.CORS_ORIGIN,
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
AUTH_JWT_SECRET:
process.env.AUTH_JWT_SECRET?.trim() ||
process.env.JWT_SECRET?.trim() ||
(isProd ? "" : "dev-secret-change-me"),
AUTH_ISSUER:
process.env.AUTH_ISSUER?.trim() ||
process.env.ISSUER?.trim() ||
"https://auth.shnt.top",
AUTH_PORTAL_URL: (
process.env.AUTH_PORTAL_URL ??
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
"http://localhost:5175"
).replace(/\/$/, ""),
}
const parsed = envSchema.safeParse(raw)
if (!parsed.success) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
process.exit(1)
}
export const env = {
DATABASE_PATH: parsed.data.DATABASE_PATH,
PORT: parsed.data.PORT,
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
authRequired: parsed.data.AUTH_REQUIRED,
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
authIssuer: parsed.data.AUTH_ISSUER,
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
}
+183
View File
@@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS traffic_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
peer_public_key TEXT NOT NULL DEFAULT '',
sampled_at TEXT NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
@@ -120,6 +121,63 @@ CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
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 traffic_flow_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
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 INTEGER,
retention_hours INTEGER NOT NULL DEFAULT 24,
top_n INTEGER NOT NULL DEFAULT 200,
last_datagram_at TEXT,
last_exporter_ip TEXT,
last_error TEXT,
packets_received INTEGER NOT NULL DEFAULT 0,
peers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS flow_buckets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
bucket_at TEXT 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 INTEGER NOT NULL DEFAULT 0,
packets INTEGER NOT NULL DEFAULT 0,
in_iface TEXT NOT NULL DEFAULT '',
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface);
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time
ON flow_buckets(server_id, bucket_at);
CREATE TABLE IF NOT EXISTS flow_ip_meta (
prefix TEXT PRIMARY KEY,
asn INTEGER NOT NULL DEFAULT 0,
country TEXT NOT NULL DEFAULT '',
lat REAL,
lng REAL,
holder TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1,
fetched_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS flow_asn_meta (
asn INTEGER PRIMARY KEY,
holder TEXT NOT NULL DEFAULT '',
fetched_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS uptime_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
@@ -511,9 +569,102 @@ CREATE TABLE IF NOT EXISTS alert_engine_cursor (
last_source_finished_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
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',
active INTEGER NOT NULL DEFAULT 1,
avatar TEXT NOT NULL DEFAULT '',
last_seen TEXT,
sections_json TEXT NOT NULL DEFAULT '[]',
servers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other',
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, interface_name, peer_public_key)
);
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
ON user_interface_bindings(user_id);
`)
// Lightweight schema evolution for existing databases without migrations
{
const sampleCols = sqlite.prepare(`PRAGMA table_info('traffic_samples')`).all() as Array<{ name?: string }>
if (!sampleCols.some((c) => c.name === "peer_public_key")) {
sqlite.exec(`ALTER TABLE traffic_samples ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
}
}
{
const bindCols = sqlite.prepare(`PRAGMA table_info('user_interface_bindings')`).all() as Array<{ name?: string }>
if (!bindCols.some((c) => c.name === "peer_public_key")) {
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
}
if (!bindCols.some((c) => c.name === "peer_name")) {
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_name TEXT NOT NULL DEFAULT ''`)
}
const indexes = sqlite.prepare(`PRAGMA index_list('user_interface_bindings')`).all() as Array<{
name?: string
unique?: number
}>
let hasPeerUnique = false
for (const idx of indexes) {
if (!idx.name || !idx.unique) continue
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
const names = info.map((c) => c.name)
if (names.includes("server_id") && names.includes("interface_name") && names.includes("peer_public_key")) {
hasPeerUnique = true
}
}
if (!hasPeerUnique) {
sqlite.exec(`PRAGMA foreign_keys = OFF`)
sqlite.exec(`
CREATE TABLE user_interface_bindings_new (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other',
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, interface_name, peer_public_key)
);
INSERT INTO user_interface_bindings_new
(id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name, comment, created_at, updated_at)
SELECT id, user_id, server_id, interface_name, interface_type,
COALESCE(peer_public_key, ''), COALESCE(peer_name, ''), comment, created_at, updated_at
FROM user_interface_bindings;
DROP TABLE user_interface_bindings;
ALTER TABLE user_interface_bindings_new RENAME TO user_interface_bindings;
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
`)
sqlite.exec(`PRAGMA foreign_keys = ON`)
}
}
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
if (!hasCountryColumn) {
@@ -580,6 +731,9 @@ if (!serverCols.some((c) => c.name === "lan_subnet")) {
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
}
if (!serverCols.some((c) => c.name === "mgmt_tunnel_ip")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN mgmt_tunnel_ip 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")) {
@@ -625,6 +779,12 @@ SELECT 1, 1, 30, 14
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO traffic_flow_settings (id, enabled, collector_ip, flow_listen_port, wg_listen_port, prefix)
SELECT 1, 0, '10.255.254.1', 4739, 51821, '10.255.254.0/24'
WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14
@@ -661,6 +821,29 @@ SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
`)
{
const flowIndexes = sqlite.prepare(`PRAGMA index_list('flow_buckets')`).all() as Array<{
name?: string
unique?: number
}>
let hasIfaceUnique = false
for (const idx of flowIndexes) {
if (!idx.name || !idx.unique) continue
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
const names = info.map((c) => c.name)
if (names.includes("in_iface") && names.includes("src") && names.includes("dst")) {
hasIfaceUnique = true
}
}
if (!hasIfaceUnique) {
sqlite.exec(`DROP INDEX IF EXISTS idx_flow_buckets_unique`)
sqlite.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
`)
}
}
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'`)
+107
View File
@@ -4,6 +4,7 @@ import {
real,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core"
// ── servers ────────────────────────────────────────────────────────────────────
@@ -31,6 +32,8 @@ export const servers = sqliteTable("servers", {
lanSubnet: text("lan_subnet").notNull().default(""),
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
wanUplinks: text("wan_uplinks").notNull().default("[]"),
/** Адрес в оверлее wg-flow (экспортёр IPFIX), например 10.255.254.5 */
mgmtTunnelIp: text("mgmt_tunnel_ip").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
@@ -157,12 +160,72 @@ export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", {
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).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: integer("hub_server_id"),
retentionHours: integer("retention_hours").notNull().default(24),
topN: integer("top_n").notNull().default(200),
lastDatagramAt: text("last_datagram_at"),
lastExporterIp: text("last_exporter_ip"),
lastError: text("last_error"),
packetsReceived: integer("packets_received").notNull().default(0),
peersJson: text("peers_json").notNull().default("[]"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const flowBuckets = sqliteTable("flow_buckets", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
bucketAt: text("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: integer("bytes").notNull().default(0),
packets: integer("packets").notNull().default(0),
inIface: text("in_iface").notNull().default(""),
}, (t) => [
uniqueIndex("idx_flow_buckets_unique").on(
t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort, t.inIface,
),
])
export const flowIpMeta = sqliteTable("flow_ip_meta", {
prefix: text("prefix").primaryKey(),
asn: integer("asn").notNull().default(0),
country: text("country").notNull().default(""),
lat: real("lat"),
lng: real("lng"),
holder: text("holder").notNull().default(""),
ok: integer("ok").notNull().default(1),
fetchedAt: text("fetched_at").notNull(),
})
export const flowAsnMeta = sqliteTable("flow_asn_meta", {
asn: integer("asn").primaryKey(),
holder: text("holder").notNull().default(""),
fetchedAt: text("fetched_at").notNull(),
})
export const trafficSamples = sqliteTable("traffic_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
peerPublicKey: text("peer_public_key").notNull().default(""),
sampledAt: text("sampled_at").notNull(),
rxBytes: integer("rx_bytes").notNull().default(0),
txBytes: integer("tx_bytes").notNull().default(0),
@@ -540,6 +603,44 @@ export const internetPathSettings = sqliteTable("internet_path_settings", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── app users (local catalog, not portal JWT) ────────────────────────────────
export const appUsers = sqliteTable("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: integer("active", { mode: "boolean" }).notNull().default(true),
avatar: text("avatar").notNull().default(""),
lastSeen: text("last_seen"),
sectionsJson: text("sections_json").notNull().default("[]"),
serversJson: text("servers_json").notNull().default("[]"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const userInterfaceBindings = sqliteTable("user_interface_bindings", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => appUsers.id, { onDelete: "cascade" }),
serverId: integer("server_id")
.notNull()
.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: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
}, (t) => [
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
])
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
id: integer("id").primaryKey({ autoIncrement: true }),
sampledAt: text("sampled_at").notNull(),
@@ -555,6 +656,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
@@ -582,3 +687,5 @@ 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
+112 -62
View File
@@ -1,14 +1,16 @@
import Fastify from "fastify"
import Fastify, { type FastifyInstance } from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { env } from "./config.js"
import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
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"
@@ -22,73 +24,121 @@ import backupsRoutes from "./routes/backups.js"
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 { startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
// ── app factory ────────────────────────────────────────────────────────────────
export async function buildApp(opts?: {
logger?: boolean
startScheduler?: boolean
}): Promise<FastifyInstance> {
const usePrettyLogger =
opts?.logger !== false && process.env.NODE_ENV !== "production"
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger:
opts?.logger === false
? false
: usePrettyLogger
? {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
}
: true,
})
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger: {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
},
})
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// Use Zod for request validation and response serialization
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
// CORS — allow Next.js frontend
await app.register(cors, {
origin: env.CORS_ORIGIN,
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
await app.register(authPlugin)
// ── routes ─────────────────────────────────────────────────────────────────────
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/api/auth/config", async () => ({
required: env.authRequired,
portal_url: env.authPortalUrl,
issuer: env.authIssuer,
}))
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
if (env.authRequired) {
app.addHook("preHandler", async (request, reply) => {
const pathname = request.url.split("?")[0] ?? request.url
if (!pathname.startsWith("/api/")) return
if (pathname === "/api/auth/config") return
await requireAuth(request, reply)
if (reply.sent) return
})
}
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
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" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
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" })
// ── start ──────────────────────────────────────────────────────────────────────
if (opts?.startScheduler !== false) {
refreshScheduler()
startTrafficFlowListener()
app.addHook("onClose", async () => {
stopScheduler()
stopTrafficFlowListener()
})
}
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
app.log.error(err)
process.exit(1)
return app
}
const isMain =
process.argv[1] &&
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
if (isMain) {
try {
const app = await buildApp()
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
console.error(err)
process.exit(1)
}
}
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict"
import { hasPermission, permissionForRequest } from "./permissions.js"
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
assert.equal(
permissionForRequest("GET", "/api/servers"),
"mm:servers:read",
)
assert.equal(
permissionForRequest("POST", "/api/servers"),
"mm:servers:write",
)
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("GET", "/api/unknown-thing"),
"mm:dashboard:read",
)
assert.equal(
permissionForRequest("GET", "/api/wireguard"),
"mm:network:read",
)
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")
+189
View File
@@ -0,0 +1,189 @@
/**
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
* Format: mm:<section>:<read|write|admin>
*/
export type AuthUser = {
id: string
email: string
name: string
apps: string[]
permissions: string[]
isAdmin?: boolean
}
export function hasPermission(
granted: readonly string[],
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
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
type Rule = {
methods: string[]
match: (path: string) => boolean
permission: string
}
const RULES: Rule[] = [
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/system") ||
p.startsWith("/api/scheduler") ||
p.startsWith("/api/evobgp"),
permission: "mm:settings:admin",
},
{
methods: ["GET"],
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",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
permission: "mm:network:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
permission: "mm:network:write",
},
]
/** Resolve required permission for method+path, or null if public / unknown. */
export function permissionForRequest(
method: string,
path: string,
): string | null {
const m = method.toUpperCase()
const pathname = path.split("?")[0] ?? path
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue
if (rule.match(pathname)) return rule.permission
}
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
return null
}
@@ -0,0 +1,90 @@
import assert from "node:assert/strict"
import Database from "better-sqlite3"
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
const sqlite = new Database(":memory:")
sqlite.pragma("foreign_keys = ON")
sqlite.exec(`
CREATE TABLE servers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '127.0.0.1'
);
CREATE TABLE 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',
active INTEGER NOT NULL DEFAULT 1,
avatar TEXT NOT NULL DEFAULT '',
last_seen TEXT,
sections_json TEXT NOT NULL DEFAULT '[]',
servers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other',
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, interface_name, peer_public_key)
);
`)
sqlite.prepare("INSERT INTO servers (id, name, host) VALUES (1, 'jh', '10.0.0.1')").run()
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u1', 'A', 'a.user')").run()
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u2', 'B', 'b.user')").run()
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b1', 'u1', 1, 'gre-office', 'gre')
`).run()
assert.throws(
() => sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b2', 'u2', 1, 'gre-office', 'gre')
`).run(),
/UNIQUE/i,
"один интерфейс на сервере — один пользователь",
)
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg1', 'u1', 1, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
`).run()
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg2', 'u2', 1, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
`).run()
assert.throws(
() => sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
VALUES ('wg3', 'u2', 1, 'wg-server', 'wg', 'peer-key-aaa')
`).run(),
/UNIQUE/i,
"один пир — один пользователь",
)
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")
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
assert.equal(leftover.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
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: string | null | undefined): ParsedRosIface[] {
if (!json) return []
try {
const parsed = JSON.parse(json) as unknown
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.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,77 @@
import { and, eq } from "drizzle-orm"
import { db } from "../../../db/index.js"
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
export type AppUserRow = typeof appUsers.$inferSelect
export type BindingRow = typeof userInterfaceBindings.$inferSelect
export function listUserRows(): AppUserRow[] {
return db.select().from(appUsers).all()
}
export function getUserRowById(id: string): AppUserRow | undefined {
return db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1).all()[0]
}
export function getUserRowByLogin(login: string): AppUserRow | undefined {
return db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1).all()[0]
}
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
const [inserted] = db.insert(appUsers).values(values).returning().all()
return inserted
}
export function updateUserRowById(
id: string,
values: Partial<AppUserRow>,
): AppUserRow {
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
return updated
}
export function deleteUserRowById(id: string): void {
db.delete(appUsers).where(eq(appUsers.id, id)).run()
}
export function listBindingRows(): BindingRow[] {
return db.select().from(userInterfaceBindings).all()
}
export function listBindingRowsByUser(userId: string): BindingRow[] {
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
}
export function getBindingRowById(id: string): BindingRow | undefined {
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
}
export function getBindingByServerIfacePeer(
serverId: number,
interfaceName: string,
peerPublicKey = "",
): BindingRow | undefined {
return db
.select()
.from(userInterfaceBindings)
.where(and(
eq(userInterfaceBindings.serverId, serverId),
eq(userInterfaceBindings.interfaceName, interfaceName),
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
))
.limit(1)
.all()[0]
}
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
return inserted
}
export function deleteBindingRowById(id: string): void {
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
}
export function countUserRows(): number {
return db.select().from(appUsers).all().length
}
@@ -0,0 +1,343 @@
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 { 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 parseJsonArray<T>(raw: string, fallback: T[]): T[] {
try {
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? (parsed as T[]) : fallback
} catch {
return 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() || "??"
}
function serverMeta(serverId: number): { name: string; site: string; country: string } {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
return {
name: row?.name || row?.host || String(serverId),
site: row?.site || "—",
country: row?.country || "UN",
}
}
function toBindingDto(row: BindingRow): UserBinding {
const meta = 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,
}
}
function toUserDto(row: AppUserRow, bindings: BindingRow[]): 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: parseJsonArray<SectionPerm>(row.sectionsJson, []),
servers: parseJsonArray<ServerPerm>(row.serversJson, []),
bindings: bindings.map(toBindingDto),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export function listUsers(): AppUserRead[] {
const users = listUserRows()
const allBindings = 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 users.map((u) => toUserDto(u, byUser.get(u.id) ?? []))
}
export function getUserById(id: string): AppUserRead | undefined {
const row = getUserRowById(id)
if (!row) return undefined
return toUserDto(row, listBindingRowsByUser(id))
}
export function createUser(input: AppUserCreate): AppUserRead {
const login = input.login.trim()
if (getUserRowByLogin(login)) {
throw new UsersServiceError("Логин уже занят", 409)
}
const now = new Date().toISOString()
const row = 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: JSON.stringify(input.sections ?? []),
serversJson: JSON.stringify(input.servers ?? []),
createdAt: now,
updatedAt: now,
})
return toUserDto(row, [])
}
export function updateUser(id: string, input: AppUserUpdate): AppUserRead {
const existing = getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
if (input.login != null) {
const other = 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 = JSON.stringify(input.sections)
if (input.servers != null) patch.serversJson = JSON.stringify(input.servers)
const updated = updateUserRowById(id, patch)
return toUserDto(updated, listBindingRowsByUser(id))
}
export function deleteUser(id: string): void {
const existing = getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
deleteUserRowById(id)
}
export function addBinding(userId: string, input: UserBindingCreate): UserBinding {
const user = getUserRowById(userId)
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
const server = db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1).all()[0]
if (!server) throw new UsersServiceError("Сервер не найден", 404)
const ifaceName = input.interfaceName.trim()
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
const type: InterfaceType = input.interfaceType ?? 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 = getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
if (taken) {
throw new UsersServiceError(
type === "wg"
? "Этот пир уже привязан к другому пользователю"
: "Интерфейс уже привязан к другому пользователю",
409,
)
}
const now = new Date().toISOString()
try {
const row = createBindingRow({
id: randomUUID(),
userId,
serverId: input.serverId,
interfaceName: ifaceName,
interfaceType: type,
peerPublicKey,
peerName,
comment: (input.comment ?? "").trim(),
createdAt: now,
updatedAt: now,
})
return toBindingDto(row)
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new UsersServiceError(
type === "wg"
? "Этот пир уже привязан к другому пользователю"
: "Интерфейс уже привязан к другому пользователю",
409,
)
}
throw err
}
}
export function removeBinding(userId: string, bindingId: string): void {
const row = getBindingRowById(bindingId)
if (!row || row.userId !== userId) {
throw new UsersServiceError("Привязка не найдена", 404)
}
deleteBindingRowById(bindingId)
}
function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
const snap = 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 server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) throw new UsersServiceError("Сервер не найден", 404)
const snap = getLatestSnapshot(serverId)
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
if (ifaces.length === 0) {
const last = db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.orderBy(desc(trafficSamples.sampledAt))
.limit(1)
.all()[0]
if (last) {
const rows = db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
running: trafficSamples.running,
disabled: trafficSamples.disabled,
})
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.all()
.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 = listBindingRows().filter((b) => b.serverId === serverId)
const usersById = new Map(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 }
+72
View File
@@ -0,0 +1,72 @@
/**
* Smoke: AUTH_REQUIRED gate via Fastify inject.
* Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
*/
import assert from "node:assert/strict"
import { SignJWT } from "jose"
process.env.AUTH_REQUIRED = "true"
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
const { buildApp } = await import("../index.js")
const secret = new TextEncoder().encode("test-secret-at-least-8")
async function mint(payload: Record<string, unknown>): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuer("https://auth.test.local")
.setExpirationTime("1h")
.sign(secret)
}
const app = await buildApp({ logger: false, startScheduler: false })
const health = await app.inject({ method: "GET", url: "/health" })
assert.equal(health.statusCode, 200)
const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
assert.equal(cfg.statusCode, 200)
assert.equal(cfg.json().required, true)
const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
assert.equal(noToken.statusCode, 401)
const badApp = await mint({
sub: "u1",
email: "[email protected]",
name: "A",
apps: ["cdn"],
permissions: ["cdn:dashboard:read"],
})
const forbiddenApp = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${badApp}` },
})
assert.equal(forbiddenApp.statusCode, 403)
const okToken = await mint({
sub: "u1",
email: "[email protected]",
name: "A",
apps: ["mm"],
permissions: ["mm:dashboard:read"],
})
const ok = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${okToken}` },
})
// May be 200 or 500 if DB missing — must not be 401/403
assert.notEqual(ok.statusCode, 401)
assert.notEqual(ok.statusCode, 403)
await app.close()
console.log("auth.smoke.test.ts: ok")
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
import fp from "fastify-plugin"
import { env } from "../config.js"
import {
hasPermission,
permissionForRequest,
type AuthUser,
} from "../lib/permissions.js"
declare module "fastify" {
interface FastifyRequest {
authUser?: AuthUser
}
}
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
exp?: number
}
user: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
exp?: number
}
}
}
async function authPlugin(app: FastifyInstance) {
if (env.authRequired && env.jwtSecret.length < 8) {
throw new Error("AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
}
await app.register(import("@fastify/jwt"), {
secret: env.jwtSecret,
...(env.authRequired
? {
verify: {
allowedIss: [env.authIssuer],
},
}
: {}),
})
if (env.authRequired) {
app.log.info(
{ issuer: env.authIssuer, portal: env.authPortalUrl },
"AUTH_REQUIRED=true — portal JWT middleware enabled",
)
} else {
app.log.info("AUTH_REQUIRED=false — /api/* open without JWT")
}
}
/**
* Protect /api/* when AUTH_REQUIRED=true.
* Public: /health, /api/auth/config
*/
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
if (!env.authRequired) return
const pathname = (request.url.split("?")[0] ?? request.url)
if (pathname === "/api/auth/config") return
const authHeader = request.headers.authorization ?? ""
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
if (!token) {
return reply.code(401).send({ error: "Unauthorized" })
}
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({ error: "Unauthorized" })
}
const payload = request.user
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []
const permissions = Array.isArray(payload.permissions)
? payload.permissions.map(String)
: []
if (!apps.includes("mm")) {
return reply
.code(403)
.send({ error: "Нет доступа к приложению MikrotikManager" })
}
request.authUser = {
id: String(payload.sub),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps,
permissions,
isAdmin: Boolean(payload.is_admin),
}
const required = permissionForRequest(request.method, pathname)
if (required && !hasPermission(permissions, required)) {
return reply.code(403).send({ error: `Недостаточно прав: ${required}` })
}
}
export default fp(authPlugin, { name: "auth" })
+19 -8
View File
@@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string {
}
}
/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */
function normalizeApiKey(raw: string): string {
const trimmed = raw.trim()
return trimmed.replace(/^Bearer\s+/i, "").trim()
}
interface EvoCatalogRaw {
modules: { items: Array<{ id: string; name: string; type: string }> }
domains: {
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl)
const apiKey = row.apiKey.trim()
const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null
return { root, apiKey }
}
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
enabled: Boolean(row.enabled),
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
})
})
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
let nextEnabled = cur.enabled
let nextKey = cur.apiKey
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
if (parsed.data.baseUrl !== undefined) {
nextBase = normalizeBaseUrl(parsed.data.baseUrl)
}
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
if (parsed.data.apiKey !== undefined) {
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
nextKey =
parsed.data.apiKey === null || parsed.data.apiKey === ""
? ""
: normalizeApiKey(parsed.data.apiKey)
}
db.update(evobgpSettings)
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
enabled: Boolean(row.enabled),
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
})
})
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const keyRaw =
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
const root = normalizeBaseUrl(urlRaw.trim())
const token = keyRaw.trim()
const token = normalizeApiKey(keyRaw)
if (!root || !token) {
return reply.status(400).send({
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
+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)
}
function requireServer(serverId: string) {
return 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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
+8
View File
@@ -1,5 +1,6 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { listCertificatesFromServers } from "../services/certificates-service.js"
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
import { db } from "../db/index.js"
import {
filterRules,
@@ -8,6 +9,7 @@ import {
uptimeProbes,
uptimeSpeedProbes,
} from "../db/schema.js"
import { listUsers } from "../modules/users/service/users-service.js"
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/sidebar-counts", async (_req, reply) => {
@@ -18,6 +20,8 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
uptimeSpeedProbesTotal,
recursiveRoutesTotal,
certificatesTotal,
wireguardTotal,
usersTotal,
] = await Promise.all([
Promise.resolve(db.select().from(servers).all().length),
Promise.resolve(db.select().from(filterRules).all().length),
@@ -25,6 +29,8 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
Promise.resolve(db.select().from(recursiveRoutes).all().length),
listCertificatesFromServers().then((res) => res.certificates.length),
countWireGuardInterfaces().catch(() => 0),
Promise.resolve(listUsers().length),
])
return reply.send({
@@ -35,6 +41,8 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal,
wireguard: wireguardTotal,
users: usersTotal,
})
})
}
+209
View File
@@ -0,0 +1,209 @@
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,
startTrafficFlowListener,
listFlowTalkers,
} from "../services/traffic-flow-ingest.js"
import {
buildFlowAnalytics,
listFlowClients,
listFlowExporters,
} from "../services/traffic-flow-analytics.js"
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
const LIVE_TICK_MS = 2000
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
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 }
return {
minutes: rangeToMinutes(q.range),
serverId: parseId(q.serverId),
userId: q.userId?.trim() || undefined,
iface: q.iface?.trim() || undefined,
dedup: parseDedup(q.dedup),
}
}
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
const q = req.query as { range?: string }
return reply.send(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(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() })
}
updateTrafficFlowSettings(parsed.data)
startTrafficFlowListener()
return reply.send({ ok: true, settings: toTrafficFlowSettingsDto(getFlowListenerState()) })
})
app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => {
const result = ensureHostKeys()
return reply.send({
ok: true,
created: result.created,
publicKey: result.publicKey,
settings: toTrafficFlowSettingsDto(getFlowListenerState()),
})
})
app.get("/traffic/flow/host-files", async (_req, reply) => {
const row = getTrafficFlowSettingsRow()
if (!row.hostPrivateKey) ensureHostKeys()
return reply.send({ files: listTrafficFlowHostFiles() })
})
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(listFlowExporters(rangeToMinutes(q.range)))
})
app.get("/traffic/flow/clients", async (req, reply) => {
const q = req.query as { range?: string }
return reply.send(listFlowClients(rangeToMinutes(q.range)))
})
app.get("/traffic/flow/analytics", async (req, reply) => {
return reply.send(buildFlowAnalytics(analyticsQuery(req)))
})
app.get("/traffic/flow/live", async (req, reply) => {
const query = analyticsQuery(req)
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) {
writeSse(reply.raw, "sample", buildFlowAnalytics(query))
await sleep(LIVE_TICK_MS, abort.signal)
}
} catch {
/* abort / disconnect */
} finally {
req.raw.off("close", onClose)
try {
reply.raw.end()
} catch {
/* already closed */
}
}
})
}
export default trafficFlowRoutes
+249 -95
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,6 +54,9 @@ interface TrafficInterfaceDto {
txNow: number
}
const LIVE_TICK_MS = 1500
const LIVE_ROS_TIMEOUT_MS = 4000
function latestSnapshot(serverId: number): SnapshotRow | undefined {
return db
.select()
@@ -61,14 +78,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,88 +91,135 @@ 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,
}
}
function snapshotStatus(serverId: number): TrafficServerDto["status"] {
const snap = 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()
@@ -243,20 +299,77 @@ 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 rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
const sinceIso = new Date(rangeStartMs).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)
return buildServerTraffic(s, 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 = 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)
@@ -270,6 +383,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const rows = 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 +391,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))
@@ -298,15 +417,50 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
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 rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
const sinceIso = new Date(rangeStartMs).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 data = buildServerTraffic(
server,
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: 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 = 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: buildBoundInterfaceTraffic(rangeStartMs, rangeEndMs) })
})
}
export default trafficRoutes
+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: 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 = 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 = 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 = 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 {
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 = 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 {
removeBinding(id, bindingId)
return reply.status(204).send()
} catch (err) {
return sendServiceError(reply, err)
}
})
}
export default usersRoutes
+442
View File
@@ -0,0 +1,442 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
wgCreateInterfaceSchema,
wgCreatePeerRequestSchema,
wgExportRequestSchema,
wgImportRequestSchema,
wgPatchInterfaceSchema,
wgPatchPeerSchema,
type WgCreatePeerRequest,
type WgIfaceDto,
} from "@mmapp/contracts/wireguard"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import {
generateMikrotikRsc,
generateNativeConf,
generatePeerClientConf,
parseWgConfig,
type WgParsedConfig,
} from "../services/wireguard-config.js"
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)
}
function rosIdParam(v: string): string {
return decodeURIComponent(v)
}
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
return toRosBody({
interface: p.interfaceName,
"public-key": p.publicKey,
"allowed-address": p.allowedAddresses.join(","),
"endpoint-address": p.endpointAddress,
"endpoint-port": p.endpointPort != null ? String(p.endpointPort) : undefined,
"persistent-keepalive":
p.persistentKeepalive != null ? String(p.persistentKeepalive) : undefined,
comment: p.comment,
name: p.name,
"private-key": typeof p.privateKey === "string" ? p.privateKey : undefined,
"client-address": p.clientAddress,
"client-dns": p.clientDns,
"client-endpoint": p.clientEndpoint,
disabled: p.disabled === true ? "yes" : p.disabled === false ? "no" : undefined,
})
}
function previewFromParsed(parsed: WgParsedConfig) {
return {
format: parsed.format,
interface: {
name: parsed.interface.name,
listenPort: parsed.interface.listenPort,
mtu: parsed.interface.mtu,
privateKey: parsed.interface.privateKey,
comment: parsed.interface.comment,
address: parsed.interface.address,
disabled: parsed.interface.disabled,
},
peers: parsed.peers.map((p) => ({
publicKey: p.publicKey,
allowedAddresses: p.allowedAddresses,
endpointAddress: p.endpointAddress,
endpointPort: p.endpointPort,
persistentKeepalive: p.persistentKeepalive,
comment: p.comment,
name: p.name,
privateKey: p.privateKey,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
disabled: p.disabled,
})),
}
}
async function applyParsedConfig(
client: MikrotikClient,
parsed: WgParsedConfig,
): Promise<{ interfaceName: string; peersCreated: number }> {
const name = parsed.interface.name
const ifaceBody = toRosBody({
name,
"listen-port": String(parsed.interface.listenPort ?? 13231),
mtu: String(parsed.interface.mtu ?? 1420),
"private-key": parsed.interface.privateKey,
comment: parsed.interface.comment,
disabled: parsed.interface.disabled ? "yes" : undefined,
})
await putWireguardInterface(client, ifaceBody)
if (parsed.interface.address) {
await putIpAddress(client, parsed.interface.address, name)
}
let peersCreated = 0
for (const p of parsed.peers) {
if (!p.publicKey) continue
await putWireguardPeer(
client,
peerToRosBody({
interfaceName: name,
publicKey: p.publicKey,
allowedAddresses: p.allowedAddresses.length ? p.allowedAddresses : ["0.0.0.0/0"],
endpointAddress: p.endpointAddress,
endpointPort: p.endpointPort,
persistentKeepalive: p.persistentKeepalive,
comment: p.comment,
name: p.name,
privateKey: p.privateKey,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
disabled: p.disabled,
}),
)
peersCreated += 1
}
return { interfaceName: name, peersCreated }
}
function findIface(
list: WgIfaceDto[],
serverId: string,
interfaceName: string,
): WgIfaceDto | undefined {
return list.find((i) => i.serverId === serverId && i.name === interfaceName)
}
const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/wireguard", async (req, reply) => {
const q = req.query as { serverId?: string; includePrivateKey?: string }
const includePrivateKey = q.includePrivateKey === "1" || q.includePrivateKey === "true"
const result = await listWireGuardInterfaces({
serverId: q.serverId,
includePrivateKey,
})
return reply.send(result)
})
app.post("/wireguard/interfaces", async (req, reply) => {
const parsed = wgCreateInterfaceSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
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 putIpAddress(client, body.address, body.name)
}
if (body.peer) {
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
}
const list = await listWireGuardInterfaces({
serverId: String(server.id),
includePrivateKey: true,
})
const created = list.interfaces.find((i) => i.name === body.name)
return reply.status(201).send(created ?? { ok: true, name: body.name })
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.patch("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = wgPatchInterfaceSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const d = parsed.data
try {
await client.patch(
`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`,
toRosBody({
name: d.name,
"listen-port": d.listenPort != null ? String(d.listenPort) : undefined,
mtu: d.mtu != null ? String(d.mtu) : undefined,
comment: d.comment,
"private-key": d.privateKey,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}),
)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.post("/wireguard/peers", async (req, reply) => {
const parsed = wgCreatePeerRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await putWireguardPeer(client, peerToRosBody(body))
return reply.status(201).send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.patch("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = wgPatchPeerSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const d = parsed.data
const client = MikrotikClient.fromServer(server)
try {
await client.patch(
`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`,
toRosBody({
"public-key": d.publicKey,
"allowed-address": d.allowedAddresses?.join(","),
"endpoint-address": d.endpointAddress,
"endpoint-port": d.endpointPort != null ? String(d.endpointPort) : undefined,
"persistent-keepalive":
d.persistentKeepalive != null ? String(d.persistentKeepalive) : undefined,
comment: d.comment,
name: d.name,
"client-address": d.clientAddress,
"client-dns": d.clientDns,
"client-endpoint": d.clientEndpoint,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}),
)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.post("/wireguard/import", async (req, reply) => {
const parsed = wgImportRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
let config: WgParsedConfig
try {
config = parseWgConfig(body.content, body.format)
} catch (e) {
return reply.status(400).send({ error: e instanceof Error ? e.message : "Ошибка разбора конфига" })
}
const preview = previewFromParsed(config)
if (body.dryRun) {
return reply.send({ dryRun: true, preview })
}
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const applied = await applyParsedConfig(client, config)
return reply.send({ dryRun: false, preview, applied })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}`, preview })
}
})
app.post("/wireguard/export", async (req, reply) => {
const parsed = wgExportRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = 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)
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
if (body.format === "rsc") {
const content = generateMikrotikRsc({
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
comment: iface.comment,
enabled: iface.enabled,
privateKey: body.includePrivateKey ? iface.privateKey : undefined,
publicKey: iface.publicKey,
address: iface.address,
serverName: iface.serverName,
peers: iface.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedIps,
endpoint: p.endpoint,
persistentKeepalive: p.persistentKeepalive,
persistent: p.persistent,
comment: p.comment,
name: p.name,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
})),
})
return reply.send({
format: "rsc",
filename: `${iface.name}.rsc`,
content,
})
}
if (body.format === "conf") {
const content = generateNativeConf(
{
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
comment: iface.comment,
enabled: iface.enabled,
privateKey: iface.privateKey,
publicKey: iface.publicKey,
address: iface.address,
serverName: iface.serverName,
peers: iface.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedIps,
endpoint: p.endpoint,
persistentKeepalive: p.persistentKeepalive,
persistent: p.persistent,
comment: p.comment,
})),
},
{ includePrivateKey: body.includePrivateKey === true },
)
return reply.send({
format: "conf",
filename: `${iface.name}.conf`,
content,
})
}
// peer-conf
const peer = body.peerId
? iface.peers.find((p) => p.id === body.peerId || p.rosId === body.peerId)
: iface.peers[0]
if (!peer) return reply.status(404).send({ error: "Пир не найден" })
if (!iface.publicKey) {
return reply.status(400).send({ error: "У интерфейса нет public-key" })
}
const endpoint =
peer.clientEndpoint ||
(peer.endpoint
? peer.endpoint
: undefined)
const content = generatePeerClientConf({
peerAddress: peer.clientAddress,
peerDns: peer.clientDns,
serverPublicKey: iface.publicKey,
allowedIps: peer.allowedIps.length ? peer.allowedIps : ["0.0.0.0/0"],
endpoint:
endpoint ||
(peer.clientEndpoint
? peer.clientEndpoint.includes(":")
? peer.clientEndpoint
: `${peer.clientEndpoint}:${iface.listenPort}`
: undefined),
persistentKeepalive: peer.persistentKeepalive ?? 25,
})
return reply.send({
format: "peer-conf",
filename: `${iface.name}-peer.conf`,
content,
})
})
}
export default wireguardRoutes
+30 -8
View File
@@ -161,11 +161,11 @@ async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string)
)
}
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> {
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<"updated" | "created" | "skipped_cname"> {
const records = await listDnsRecordsByName(token, zoneId, fqdn)
const existingA = records.find((record) => record.type === "A")
if (existingA) {
if (existingA.content === ip) return
if (existingA.content === ip) return "updated"
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
method: "PATCH",
body: JSON.stringify({
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false,
}),
})
return
return "updated"
}
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
if (records.some((record) => record.type === "CNAME")) {
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`)
return "skipped_cname"
}
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
@@ -193,6 +194,7 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false,
}),
})
return "created"
}
async function syncCertificateDomainRecords(
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
domains: string[],
serverIp: string,
defaultZoneId?: string,
): Promise<void> {
): Promise<{ skippedCname: string[] }> {
const skippedCname: string[] = []
for (const domain of domains) {
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
await upsertARecord(token, zoneId, domain, serverIp)
const result = await upsertARecord(token, zoneId, domain, serverIp)
if (result === "skipped_cname") skippedCname.push(domain)
}
return { skippedCname }
}
async function sleep(ms: number) {
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
const finalized = await client.finalizeOrder(order, csr)
const certPem = await client.getCertificate(finalized)
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
const clientRos = MikrotikClient.fromServer(params.server)
const serverIp = await resolveServerPublicIp(params.server, clientRos)
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId)
try {
params.onStep?.("dns_a_sync")
const serverIp = await resolveServerPublicIp(params.server, clientRos)
const { skippedCname } = await syncCertificateDomainRecords(
token,
domains,
serverIp,
settings.defaultZoneId,
)
if (skippedCname.length > 0) {
params.onStep?.(
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
)
}
} catch (e) {
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
params.onStep?.(`dns_a_sync_warn:${msg}`)
}
const trustStores = params.trustStore.filter(Boolean)
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
+198
View File
@@ -0,0 +1,198 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import {
MikrotikClient,
firewallRestPath,
} from "./mikrotik.js"
import type {
FirewallFamily,
FirewallTable,
RosFirewallAddressList,
RosFirewallFilter,
} from "../types/server.js"
type ServerRow = typeof servers.$inferSelect
export interface FirewallRuleDto {
id: string
rosId: string
serverId: string
serverName: string
family: FirewallFamily
table: FirewallTable
chain: string
action: string
proto: string
src: string
dst: string
port: string
iface: string
comment: string
enabled: boolean
hits: number
log: boolean
logPrefix: string
tlsHost?: string
layer7Proto?: string
}
export interface FirewallAddressListDto {
id: string
rosId: string
serverId: string
serverName: string
family: FirewallFamily
list: string
address: string
comment: string
disabled: boolean
timeout?: string
}
const TABLES: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
const FAMILIES: FirewallFamily[] = ["ip", "ip6"]
function dash(v: string | undefined): string {
const s = v?.trim() ?? ""
return s.length > 0 ? s : "—"
}
function rosDisabled(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function parseHits(raw: RosFirewallFilter): number {
const n = Number.parseInt(raw.packets ?? "0", 10)
return Number.isFinite(n) ? n : 0
}
export function ruleUiId(
serverId: string | number,
family: FirewallFamily,
table: FirewallTable,
rosId: string,
): string {
return `${serverId}:${family}:${table}:${rosId}`
}
export function addressUiId(
serverId: string | number,
family: FirewallFamily,
rosId: string,
): string {
return `${serverId}:${family}:address-list:${rosId}`
}
export function mapFirewallRule(
server: ServerRow,
family: FirewallFamily,
table: FirewallTable,
raw: RosFirewallFilter,
idx: number,
): FirewallRuleDto {
const rosId = raw[".id"] || `*${idx}`
const src = raw["src-address"] || raw["src-address-list"]
const dst = raw["dst-address"] || raw["dst-address-list"]
const port = raw["dst-port"] || raw["src-port"]
const iface = raw["in-interface"] || raw["out-interface"]
return {
id: ruleUiId(server.id, family, table, rosId),
rosId,
serverId: String(server.id),
serverName: server.name || server.host,
family,
table,
chain: raw.chain || "",
action: raw.action || "",
proto: raw.protocol || "all",
src: dash(src),
dst: dash(dst),
port: dash(port),
iface: dash(iface),
comment: raw.comment ?? "",
enabled: !rosDisabled(raw.disabled),
hits: parseHits(raw),
log: raw.log === "true" || raw.log === "yes",
logPrefix: raw["log-prefix"] ?? "",
tlsHost: raw["tls-host"],
layer7Proto: raw["layer7-protocol"],
}
}
export function mapAddressList(
server: ServerRow,
family: FirewallFamily,
raw: RosFirewallAddressList,
idx: number,
): FirewallAddressListDto {
const rosId = raw[".id"] || `*${idx}`
return {
id: addressUiId(server.id, family, rosId),
rosId,
serverId: String(server.id),
serverName: server.name || server.host,
family,
list: raw.list || "",
address: raw.address || "",
comment: raw.comment ?? "",
disabled: rosDisabled(raw.disabled),
timeout: raw.timeout,
}
}
async function safeGet<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T[]> {
try {
const rows = await fn()
return Array.isArray(rows) ? rows : fallback
} catch {
return fallback
}
}
export async function fetchServerFirewall(server: ServerRow): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
}> {
const client = MikrotikClient.fromServer(server)
const ruleJobs = FAMILIES.flatMap((family) =>
TABLES.map(async (table) => {
const raw = await safeGet(() => client.getFirewallRules(family, table))
return raw.map((row, idx) => mapFirewallRule(server, family, table, row, idx))
}),
)
const listJobs = FAMILIES.map(async (family) => {
const raw = await safeGet(() => client.getFirewallAddressList(family))
return raw.map((row, idx) => mapAddressList(server, family, row, idx))
})
const [ruleChunks, listChunks] = await Promise.all([
Promise.all(ruleJobs),
Promise.all(listJobs),
])
return {
rules: ruleChunks.flat(),
addressLists: listChunks.flat(),
}
}
export async function listFirewallAll(): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
}> {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const perServer = await Promise.all(
allServers.map(async (server) => {
try {
return await fetchServerFirewall(server)
} catch {
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
}
}),
)
return {
rules: perServer.flatMap((r) => r.rules),
addressLists: perServer.flatMap((r) => r.addressLists),
}
}
export { firewallRestPath, FAMILIES, TABLES }
+24 -2
View File
@@ -7,7 +7,8 @@ import type {
RosBgpSession,
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
RosIpRoute, RosFirewallFilter, RosFirewallAddressList, RosLogEntry, RosPingResult,
FirewallFamily, FirewallTable,
} from "../types/server.js"
// ── connection params ─────────────────────────────────────────────────────────
@@ -338,6 +339,19 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|| entryName.endsWith(`/${base}`)
}
export function firewallRestPath(
family: FirewallFamily,
table: FirewallTable | "address-list",
): string {
const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
return `${root}/${table}`
}
export function encodeRosId(rosId: string): string {
const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
return encodeURIComponent(id)
}
// ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient {
@@ -461,7 +475,15 @@ export class MikrotikClient {
}
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
return this.getFirewallRules("ip", "filter")
}
async getFirewallRules(family: FirewallFamily, table: FirewallTable): Promise<RosFirewallFilter[]> {
return this.get<RosFirewallFilter[]>(firewallRestPath(family, table))
}
async getFirewallAddressList(family: FirewallFamily): Promise<RosFirewallAddressList[]> {
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
}
async getLogs(limit = 50): Promise<RosLogEntry[]> {
+127 -21
View File
@@ -1,12 +1,16 @@
import { and, asc, eq, gte, lt } from "drizzle-orm"
import { and, asc, desc, eq, gte, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { MikrotikClient } from "./mikrotik.js"
import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js"
import { rememberServerIfaces } from "./traffic-flow-ifindex.js"
interface RosIfaceTraffic {
".id"?: string
name?: string
ifindex?: string
running?: string
disabled?: string
"rx-byte"?: string
@@ -15,6 +19,38 @@ interface RosIfaceTraffic {
"tx-bits-per-second"?: string
}
interface RosWgPeerTraffic {
interface?: string
name?: string
comment?: string
"public-key"?: string
rx?: string
tx?: string
disabled?: string
}
function waveKey(interfaceName: string, peerPublicKey = ""): string {
return `${interfaceName}\0${peerPublicKey}`
}
function sampleRate(
prevWave: Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>,
key: string,
rxBytes: number,
txBytes: number,
nowMs: number,
): { rxBps: number; txBps: number } {
const prev = prevWave.get(key)
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
const rxBps = prev && Number.isFinite(prevMs)
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
: 0
const txBps = prev && Number.isFinite(prevMs)
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
: 0
return { rxBps, txBps }
}
export interface TrafficCollectorState {
running: boolean
lastRunAt: string | null
@@ -50,6 +86,32 @@ function cleanupOldSamples(retentionDays: number) {
.run()
}
function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBytes: number; sampledAt: string }> {
const last = db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.orderBy(desc(trafficSamples.sampledAt))
.limit(1)
.all()[0]
if (!last) return new Map()
const rows = db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
rxBytes: trafficSamples.rxBytes,
txBytes: trafficSamples.txBytes,
sampledAt: trafficSamples.sampledAt,
})
.from(trafficSamples)
.where(and(
eq(trafficSamples.serverId, serverId),
eq(trafficSamples.sampledAt, last.sampledAt),
))
.all()
return new Map(rows.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r]))
}
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
const sampledAt = new Date().toISOString()
if (collecting) {
@@ -80,26 +142,70 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
try {
const client = MikrotikClient.fromServer(srv)
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
let sumRx = 0
let sumTx = 0
for (const i of ifaces) {
sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000
sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000
}
if (ifaces.length > 0) {
db.insert(trafficSamples).values(
ifaces.map((i) => ({
rememberServerIfaces(srv.id, ifaces)
const prevWave = readPreviousWave(srv.id)
const nowMs = Date.parse(now)
let sumRxMbps = 0
let sumTxMbps = 0
const rows = ifaces.map((i) => {
const interfaceName = i.name ?? "unknown"
const rxBytes = toNum(i["rx-byte"])
const txBytes = toNum(i["tx-byte"])
const running = (i.running ?? "false") === "true"
const disabled = (i.disabled ?? "false") === "true"
const { rxBps, txBps } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs)
if (shouldIncludeIface(interfaceName, running, disabled)) {
sumRxMbps += bpsToMbps(rxBps)
sumTxMbps += bpsToMbps(txBps)
}
return {
serverId: srv.id,
interfaceName,
peerPublicKey: "",
sampledAt: now,
rxBytes,
txBytes,
rxBps,
txBps,
running,
disabled,
}
})
try {
const peers = await client.get<RosWgPeerTraffic[]>("/interface/wireguard/peers")
for (const p of peers) {
const interfaceName = (p.interface ?? "").trim()
const peerPublicKey = (p["public-key"] ?? "").trim()
if (!interfaceName || !peerPublicKey) continue
const rxBytes = toNum(p.rx)
const txBytes = toNum(p.tx)
const disabled = (p.disabled ?? "false") === "true" || p.disabled === "yes"
const running = !disabled
const { rxBps, txBps } = sampleRate(
prevWave,
waveKey(interfaceName, peerPublicKey),
rxBytes,
txBytes,
nowMs,
)
rows.push({
serverId: srv.id,
interfaceName: i.name ?? "unknown",
interfaceName,
peerPublicKey,
sampledAt: now,
rxBytes: toNum(i["rx-byte"]),
txBytes: toNum(i["tx-byte"]),
rxBps: toNum(i["rx-bits-per-second"]),
txBps: toNum(i["tx-bits-per-second"]),
running: (i.running ?? "false") === "true",
disabled: (i.disabled ?? "false") === "true",
})),
).run()
rxBytes,
txBytes,
rxBps,
txBps,
running,
disabled,
})
}
} catch {
/* WG peers optional — iface samples already recorded */
}
if (rows.length > 0) {
db.insert(trafficSamples).values(rows).run()
}
snapshot.servers.push({
serverId: srv.id,
@@ -107,8 +213,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
host: srv.host,
ok: true,
interfaces: ifaces.length,
sumRxMbps: Math.round(sumRx),
sumTxMbps: Math.round(sumTx),
sumRxMbps: Math.round(sumRxMbps * 1000) / 1000,
sumTxMbps: Math.round(sumTxMbps * 1000) / 1000,
})
} catch (err) {
snapshot.servers.push({
@@ -0,0 +1,168 @@
import assert from "node:assert/strict"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
import {
ingestParsedFlowsForServerForTests,
resetFlowRingsForTests,
} from "./traffic-flow-ingest.js"
import { buildFlowAnalytics } from "./traffic-flow-analytics.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
disableCatalogFetchForTests()
resetFlowCatalogForTests()
disableRipePersistForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
resetIfaceCacheForTests()
resetFlowRingsForTests()
rememberServerIfaces(7, [
{ ".id": "*2", name: "ether1" },
{ ".id": "*A", name: "wg-flow" },
])
ingestParsedFlowsForServerForTests(7, [
{
src: "10.1.1.8",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 12_000,
packets: 10,
inIface: "2",
outIface: "10",
},
{
src: "10.1.1.8",
dst: "1.1.1.1",
proto: 17,
srcPort: 53000,
dstPort: 53,
bytes: 800,
packets: 4,
inIface: "2",
outIface: "",
},
])
try {
const all = buildFlowAnalytics({ minutes: 5, serverId: 7 })
assert.equal(all.applications[0]?.label, "HTTPS")
assert.ok(all.protocols.some((p) => p.label === "TCP"))
assert.equal(all.ifaces[0]?.name, "ether1")
assert.notEqual(all.ifaces[0]?.name, "2")
const conv = all.conversationsList[0]
assert.ok(conv)
assert.equal(conv.inIface, "ether1")
assert.equal(conv.inIfaceIndex, "2")
assert.equal(conv.application, "HTTPS")
assert.ok(!/^\d+$/.test(conv.inIface))
const filtered = buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "ether1" })
assert.ok(filtered.bytes >= 12_000)
assert.equal(filtered.ifaces[0]?.name, "ether1")
const miss = buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "wg-flow" })
assert.equal(miss.conversations, 0)
const other = buildFlowAnalytics({ minutes: 5, serverId: 99 })
assert.equal(other.conversations, 0)
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
rememberServerIfaces(7, [
{ ".id": "*2", name: "ether1" },
{ ".id": "*A", name: "wg-flow" },
])
ingestParsedFlowsForServerForTests(7, [
{
src: "10.1.1.8",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 12_000,
packets: 10,
inIface: "2",
outIface: "10",
},
{
src: "10.1.1.8",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 9_000,
packets: 9,
inIface: "10",
outIface: "",
},
])
try {
const summed = buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: false })
assert.equal(summed.bytes, 21_000)
assert.equal(summed.conversations, 2)
const deduped = buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: true })
assert.equal(deduped.bytes, 12_000)
assert.equal(deduped.conversations, 1)
assert.equal(deduped.dedupApplied, true)
assert.equal(deduped.interfaces.length, 2)
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
disableRipeEnqueueForTests()
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: 37.4,
lng: -122.1,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
seedFlowCatalogForTests({
cidrs: [{ cidr: "8.8.8.0/24", purpose: "steam-gaming" }],
})
rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }])
ingestParsedFlowsForServerForTests(7, [
{
src: "10.1.1.8",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 12_000,
packets: 10,
inIface: "2",
outIface: "",
},
])
try {
const geo = buildFlowAnalytics({ minutes: 5, serverId: 7 })
assert.equal(geo.categories?.[0]?.label, "Игры")
assert.ok(geo.asns?.some((r) => r.label.includes("AS15169")))
assert.equal(geo.countries?.[0]?.id, "US")
assert.equal(geo.mapEdges?.[0]?.toCountry, "US")
assert.equal(geo.conversationsList[0]?.dstCountry, "US")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
console.log("traffic-flow-analytics.test.ts: ok")
@@ -0,0 +1,406 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
import type {
FlowAnalyticsDto,
FlowBreakdownRow,
FlowClientsDto,
FlowEntityCard,
FlowExportersDto,
FlowMapEdge,
FlowTalkerDto,
} from "@mmapp/contracts/traffic-flow"
import { protoName } from "./traffic-flow-parse.js"
import {
getFlowListenerState,
getRingMbps,
listStoredFlowRows,
type PendingFlowRow,
} from "./traffic-flow-ingest.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
export interface FlowAnalyticsQuery {
minutes: number
serverId?: number
userId?: string
iface?: string
/** Default true: один 5-tuple = max байт по ifaces. */
dedup?: boolean
}
function bpsToMbps(bps: number): number {
return bps / 1_000_000
}
function topN(map: Map<string, { bytes: number; packets: number }>, windowSec: number, n: number): FlowBreakdownRow[] {
const total = [...map.values()].reduce((a, v) => a + v.bytes, 0) || 1
return [...map.entries()]
.sort((a, b) => b[1].bytes - a[1].bytes)
.slice(0, n)
.map(([id, v]) => ({
id,
label: id,
bytes: v.bytes,
packets: v.packets,
bps: (v.bytes * 8) / windowSec,
percent: (v.bytes / total) * 100,
}))
}
function bump(map: Map<string, { bytes: number; packets: number }>, id: string, bytes: number, packets: number) {
const prev = map.get(id) ?? { bytes: 0, packets: 0 }
prev.bytes += bytes
prev.packets += packets
map.set(id, prev)
}
function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
if (!userId) return null
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
const allow = new Map<number, Set<string>>()
for (const b of binds) {
const set = allow.get(b.serverId) ?? new Set<string>()
set.add(b.interfaceName)
allow.set(b.serverId, set)
}
return allow
}
function seriesFromRows(rows: PendingFlowRow[], minutes: number): { rx: number[]; tx: number[] } {
const slots = Math.min(60, Math.max(5, minutes))
const slotMs = (minutes * 60_000) / slots
const start = Date.now() - minutes * 60_000
const rx = Array(slots).fill(0) as number[]
const tx = Array(slots).fill(0) as number[]
for (const r of rows) {
const t = Date.parse(r.bucketAt)
if (!Number.isFinite(t)) continue
const idx = Math.min(slots - 1, Math.max(0, Math.floor((t - start) / slotMs)))
rx[idx] += r.bytes
}
const slotSec = Math.max(1, slotMs / 1000)
return {
rx: rx.map((b) => bpsToMbps((b * 8) / slotSec)),
tx,
}
}
function snapshotStatus(serverId: number): FlowEntityCard["status"] {
void serverId
return "online"
}
function topLabel(map: Map<string, { bytes: number; packets: number }>, fallback = "—"): string {
let best = fallback
let bestBytes = 0
for (const [label, v] of map) {
if (v.bytes > bestBytes) {
bestBytes = v.bytes
best = label
}
}
return best
}
export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
const settings = getTrafficFlowSettingsRow()
const top = Math.min(50, Math.max(10, settings.topN))
const windowSec = Math.max(60, q.minutes * 60)
const sinceIso = new Date(Date.now() - q.minutes * 60_000).toISOString()
const raw = listStoredFlowRows(sinceIso)
const allow = q.userId ? userIfaceAllow(q.userId) : null
const serverRows = db.select().from(servers).all()
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
const countryById = new Map(serverRows.map((s) => [s.id, (s.country || "").toUpperCase() || "UN"]))
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
const wantDedup = q.dedup !== false && !ifaceFilter
refreshFlowCatalogInBackground()
const applications = new Map<string, { bytes: number; packets: number }>()
const protocols = new Map<string, { bytes: number; packets: number }>()
const sources = new Map<string, { bytes: number; packets: number }>()
const destinations = new Map<string, { bytes: number; packets: number }>()
const ifacesMap = new Map<string, { bytes: number; packets: number; index: string }>()
const asns = new Map<string, { bytes: number; packets: number }>()
const countries = new Map<string, { bytes: number; packets: number }>()
const categories = new Map<string, { bytes: number; packets: number }>()
const services = new Map<string, { bytes: number; packets: number }>()
const conv = new Map<string, FlowTalkerDto & { rawBytes: number }>()
const edgeAcc = new Map<string, FlowMapEdge & { catBytes: Map<string, number> }>()
const srcs = new Set<string>()
const dsts = new Set<string>()
const matched: PendingFlowRow[] = []
for (const r of raw) {
const resolved = resolveIfaceName(r.serverId, r.inIface)
if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue
matched.push(r)
const ifaceKey = resolved.name
const prevIf = ifacesMap.get(ifaceKey) ?? { bytes: 0, packets: 0, index: resolved.index }
prevIf.bytes += r.bytes
prevIf.packets += r.packets
ifacesMap.set(ifaceKey, prevIf)
}
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
const conversationsRaw = new Set(matched.map((r) => `${flowTupleKey(r)}|${r.inIface}`)).size
let totalBytes = 0
let totalPackets = 0
for (const r of working) {
const resolved = resolveIfaceName(r.serverId, r.inIface)
totalBytes += r.bytes
totalPackets += r.packets
srcs.add(r.src)
dsts.add(r.dst)
const app = applicationName(r.proto, r.dstPort, r.srcPort)
const ripe = lookupRipeCached(r.dst)
const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe)
bump(applications, app, r.bytes, r.packets)
bump(protocols, protoName(r.proto), r.bytes, r.packets)
bump(sources, r.src, r.bytes, r.packets)
bump(destinations, r.dst, r.bytes, r.packets)
bump(categories, classified.category, r.bytes, r.packets)
bump(services, classified.service, r.bytes, r.packets)
if (ripe?.ok && ripe.asn) {
const asnLabel = ripe.holder ? `AS${ripe.asn} ${ripe.holder}` : `AS${ripe.asn}`
bump(asns, asnLabel, r.bytes, r.packets)
}
if (ripe?.ok && ripe.country && ripe.country !== "—") {
bump(countries, ripe.country, r.bytes, r.packets)
}
const ckey = wantDedup
? flowTupleKey(r)
: `${flowTupleKey(r)}|${r.inIface}`
const prev = conv.get(ckey)
if (prev) {
prev.rawBytes += r.bytes
prev.bytes += r.bytes
prev.packets += r.packets
} else {
conv.set(ckey, {
serverId: String(r.serverId),
serverName: nameById.get(r.serverId) ?? String(r.serverId),
src: r.src,
dst: r.dst,
proto: r.proto,
protoName: protoName(r.proto),
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes: r.bytes,
packets: r.packets,
bps: 0,
inIface: resolved.name,
inIfaceIndex: resolved.index,
application: app,
category: classified.category,
service: classified.service,
dstCountry: ripe?.country && ripe.country !== "—" ? ripe.country : undefined,
dstAsn: ripe?.asn || undefined,
rawBytes: r.bytes,
})
}
const toCountry = ripe?.ok && ripe.country && ripe.country !== "—" ? ripe.country : ""
if (toCountry) {
const fromCountry = countryById.get(r.serverId) || "UN"
const ekey = `${r.serverId}|${toCountry}`
let edge = edgeAcc.get(ekey)
if (!edge) {
edge = {
fromId: String(r.serverId),
fromLabel: nameById.get(r.serverId) ?? String(r.serverId),
fromCountry,
toCountry,
toAsn: ripe?.asn ?? 0,
category: classified.category,
bytes: 0,
bps: 0,
catBytes: new Map(),
}
edgeAcc.set(ekey, edge)
}
edge.bytes += r.bytes
if (ripe?.asn) edge.toAsn = ripe.asn
edge.catBytes.set(classified.category, (edge.catBytes.get(classified.category) ?? 0) + r.bytes)
}
}
enqueueRipeMisses(dsts)
const conversationsList = [...conv.values()]
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
.sort((a, b) => b.bytes - a.bytes)
.slice(0, top)
.map(({ rawBytes: _raw, ...rest }) => rest)
const topProto = topLabel(protocols)
const topCategory = topLabel(categories)
const ringServer = q.serverId ?? (matched[0]?.serverId ?? 0)
const ring = ringServer
? getRingMbps(ringServer, ifaceFilter === "" ? "__all__" : (ifacesMap.get(ifaceFilter)?.index || ifaceFilter))
: { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
const fromBuckets = seriesFromRows(matched, q.minutes)
const rxSeries = q.minutes <= 15 ? ring.rx : fromBuckets.rx
const txSeries = q.minutes <= 15 ? ring.tx : fromBuckets.tx
const ifaceRows = [...ifacesMap.entries()]
.sort((a, b) => b[1].bytes - a[1].bytes)
.map(([name, v]) => ({
name,
index: v.index,
bps: (v.bytes * 8) / windowSec,
}))
const ifaceRawBytes = [...ifacesMap.values()].reduce((a, v) => a + v.bytes, 0) || 1
const listener = getFlowListenerState()
const mapEdges: FlowMapEdge[] = [...edgeAcc.values()]
.map((e) => {
let cat = e.category
let catBest = 0
for (const [label, bytes] of e.catBytes) {
if (bytes > catBest) {
catBest = bytes
cat = label
}
}
return {
fromId: e.fromId,
fromLabel: e.fromLabel,
fromCountry: e.fromCountry,
toCountry: e.toCountry,
toAsn: e.toAsn,
category: cat,
bytes: e.bytes,
bps: (e.bytes * 8) / windowSec,
}
})
.sort((a, b) => b.bytes - a.bytes)
.slice(0, top)
return {
bpsNow: (ring.rxNow + ring.txNow) * 1_000_000 || (totalBytes * 8) / windowSec,
bytes: totalBytes,
packets: totalPackets,
conversations: conv.size,
conversationsRaw,
uniqueSrc: srcs.size,
uniqueDst: dsts.size,
topProto,
topCategory,
rxSeries,
txSeries,
applications: topN(applications, windowSec, top),
protocols: topN(protocols, windowSec, top),
sources: topN(sources, windowSec, top),
destinations: topN(destinations, windowSec, top),
interfaces: [...ifacesMap.entries()].map(([label, v]) => ({
id: label,
label,
bytes: v.bytes,
packets: v.packets,
bps: (v.bytes * 8) / windowSec,
percent: (v.bytes / ifaceRawBytes) * 100,
})).sort((a, b) => b.bytes - a.bytes),
asns: topN(asns, windowSec, top),
countries: topN(countries, windowSec, top),
categories: topN(categories, windowSec, top),
services: topN(services, windowSec, top),
mapEdges,
conversationsList,
ifaces: ifaceRows,
live: listener.bound,
dedupApplied: wantDedup,
}
}
function cardFromServer(
s: typeof servers.$inferSelect,
minutes: number,
): FlowEntityCard {
const analytics = buildFlowAnalytics({ minutes, serverId: s.id })
const ring = getRingMbps(s.id, "__all__")
return {
id: String(s.id),
name: s.name || s.host,
subtitle: s.host,
site: s.site || "—",
country: s.country || "UN",
status: snapshotStatus(s.id),
rxNow: ring.rxNow || bpsToMbps(analytics.bpsNow),
txNow: ring.txNow,
sessions: analytics.conversations,
rxSeries: ring.rx.some((v) => v > 0) ? ring.rx : analytics.rxSeries,
txSeries: ring.tx,
bytes: analytics.bytes,
}
}
export function listFlowExporters(minutes: number): FlowExportersDto {
const settings = getTrafficFlowSettingsRow()
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const rows = listStoredFlowRows(sinceIso)
const ids = new Set<number>()
for (const r of rows) ids.add(r.serverId)
for (const p of listHostPeers()) ids.add(p.serverId)
const serverRows = db.select().from(servers).all()
const exporters = serverRows
.filter((s) => ids.has(s.id))
.map((s) => cardFromServer(s, minutes))
.sort((a, b) => b.rxNow - a.rxNow)
const listener = getFlowListenerState()
return {
exporters,
lastExporterIp: settings.lastExporterIp ?? null,
lastError: settings.lastError || null,
packetsReceived: settings.packetsReceived,
lastDatagramAt: settings.lastDatagramAt ?? null,
listenerBound: listener.bound,
listenerAddress: listener.address,
}
}
export function listFlowClients(minutes: number): FlowClientsDto {
const users = db.select().from(appUsers).all()
const binds = db.select().from(userInterfaceBindings).all()
const byUser = new Map<string, typeof binds>()
for (const b of binds) {
const list = byUser.get(b.userId) ?? []
list.push(b)
byUser.set(b.userId, list)
}
const clients: FlowEntityCard[] = []
for (const u of users) {
const userBinds = byUser.get(u.id) ?? []
if (userBinds.length === 0) continue
const analytics = buildFlowAnalytics({ minutes, userId: u.id })
const firstServer = userBinds[0]?.serverId
const ring = firstServer ? getRingMbps(firstServer, "__all__") : { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
clients.push({
id: u.id,
name: u.login,
subtitle: u.name || u.login,
site: `${userBinds.length} ifaces`,
country: "UN",
status: u.active ? "online" : "offline",
rxNow: bpsToMbps(analytics.bpsNow) || ring.rxNow,
txNow: ring.txNow,
sessions: analytics.conversations,
rxSeries: analytics.rxSeries,
txSeries: analytics.txSeries,
bytes: analytics.bytes,
})
}
clients.sort((a, b) => b.rxNow - a.rxNow)
return { clients }
}
+79
View File
@@ -0,0 +1,79 @@
import { protoName } from "./traffic-flow-parse.js"
const WELL_KNOWN: Record<string, string> = {
"6:80": "HTTP",
"6:443": "HTTPS",
"6:8080": "HTTP-alt",
"6:8443": "HTTPS-alt",
"6:22": "SSH",
"6:21": "FTP",
"6:25": "SMTP",
"6:110": "POP3",
"6:143": "IMAP",
"6:993": "IMAPS",
"6:995": "POP3S",
"6:587": "SMTP",
"6:465": "SMTPS",
"6:3306": "MySQL",
"6:5432": "PostgreSQL",
"6:6379": "Redis",
"6:3389": "RDP",
"6:445": "SMB",
"6:139": "NetBIOS",
"6:179": "BGP",
"6:8291": "WinBox",
"6:8728": "ROS-API",
"6:8729": "ROS-API-SSL",
"17:53": "DNS",
"6:53": "DNS",
"17:123": "NTP",
"17:161": "SNMP",
"17:162": "SNMP-trap",
"17:500": "IKE",
"17:4500": "NAT-T",
"17:1194": "OpenVPN",
"17:51820": "WireGuard",
"17:4789": "VXLAN",
"17:4739": "IPFIX",
"17:2055": "NetFlow",
"17:67": "DHCP",
"17:68": "DHCP",
"17:69": "TFTP",
"17:1812": "RADIUS",
"1:0": "ICMP",
"47:0": "GRE",
"50:0": "ESP",
"89:0": "OSPF",
}
export function applicationName(proto: number, dstPort: number, srcPort = 0): string {
if (proto === 1) return "ICMP"
if (proto === 47) return "GRE"
if (proto === 50) return "ESP"
if (proto === 89) return "OSPF"
const dstKey = `${proto}:${dstPort}`
const srcKey = `${proto}:${srcPort}`
return WELL_KNOWN[dstKey] ?? WELL_KNOWN[srcKey] ?? `${protoName(proto)}/${dstPort || srcPort || "—"}`
}
export interface FlowMatchQuery {
serverId?: number
userId?: string
iface?: string
}
export function flowRowMatchesFilter(
row: { serverId: number; inIface: string },
resolvedName: string,
q: FlowMatchQuery,
allow: Map<number, Set<string>> | null,
): boolean {
if (q.serverId != null && row.serverId !== q.serverId) return false
if (allow) {
const names = allow.get(row.serverId)
if (!names || !names.has(resolvedName)) return false
}
const iface = q.iface && q.iface !== "__all__" ? q.iface : ""
if (iface && resolvedName !== iface && row.inIface !== iface) return false
return true
}
@@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { classifyFlowDst, disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
disableCatalogFetchForTests()
resetFlowCatalogForTests()
seedFlowCatalogForTests({
cidrs: [{ cidr: "192.0.2.0/24", purpose: "steam-gaming" }],
})
const hit = classifyFlowDst("192.0.2.10", 6, 443, 50000, null)
assert.equal(hit.category, "Игры")
assert.equal(hit.service, "steam-gaming")
const miss = classifyFlowDst("203.0.113.9", 17, 53, 53000, null)
assert.equal(miss.category, "DNS")
const cdn = classifyFlowDst("203.0.113.9", 6, 443, 1, { prefix: "203.0.113.0/24", asn: 13335, country: "US", lat: null, lng: null, holder: "CLOUDFLARENET", ok: true, fetchedAt: Date.now() })
assert.equal(cdn.category, "CDN")
console.log("traffic-flow-classify.test.ts: ok")
@@ -0,0 +1,140 @@
import { db } from "../db/index.js"
import { evobgpSettings } from "../db/schema.js"
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.js"
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
import { applicationName } from "./traffic-flow-apps.js"
export interface FlowClassification {
service: string
category: string
}
interface CatalogCidr {
cidr: string
purpose: string
prefixLen: number
}
const CATALOG_TTL_MS = 10 * 60_000
let cidrs: CatalogCidr[] = []
let asnPurpose = new Map<number, string>()
let fetchedAt = 0
let catalogFetchEnabled = true
let inflight: Promise<void> | null = null
export function disableCatalogFetchForTests(): void {
catalogFetchEnabled = false
}
export function resetFlowCatalogForTests(): void {
cidrs = []
asnPurpose = new Map()
fetchedAt = 0
inflight = null
}
export function seedFlowCatalogForTests(input: {
cidrs?: Array<{ cidr: string; purpose: string }>
asns?: Array<{ asn: number; purpose: string }>
}): void {
cidrs = (input.cidrs ?? [])
.map((c) => ({ cidr: c.cidr, purpose: c.purpose, prefixLen: parseCidrV4(c.cidr)?.prefixLen ?? 0 }))
.sort((a, b) => b.prefixLen - a.prefixLen)
asnPurpose = new Map((input.asns ?? []).map((a) => [a.asn, a.purpose]))
fetchedAt = Date.now()
}
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
const p = purpose.toLowerCase()
if (/gaming|steam|epic|riot/.test(p)) return "Игры"
if (/streaming|youtube|netflix|twitch|video/.test(p)) return "Видео / стриминг"
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
if (/voip|discord|zoom/.test(p)) return "Голос"
const app = applicationName(proto, dstPort, srcPort)
if (app === "DNS" || app === "SSH" || app === "BGP") return app
return "Проче"
}
function matchCidr(ip: string): CatalogCidr | null {
for (const row of cidrs) {
if (ipInCidrV4(ip, row.cidr)) return row
}
return null
}
export function classifyFlowDst(
dst: string,
proto: number,
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
): FlowClassification {
const hit = matchCidr(dst)
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
const purpose = hit?.purpose || asnName || ripe?.holder || ""
const service = (hit?.purpose || asnName || ripe?.holder || "Проче").trim() || "Проче"
return {
service,
category: categoryFromPurpose(purpose, proto, dstPort, srcPort),
}
}
async function fetchCatalog(): Promise<void> {
if (!catalogFetchEnabled) return
if (Date.now() - fetchedAt < CATALOG_TTL_MS) return
if (inflight) return inflight
inflight = (async () => {
try {
const row = db.select().from(evobgpSettings).limit(1).all()[0]
if (!row?.enabled) return
const root = String(row.baseUrl ?? "").replace(/\/+$/, "")
const token = String(row.apiKey ?? "").replace(/^Bearer\s+/i, "").trim()
if (!root || !token) return
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 20_000)
try {
const res = await fetch(`${root}/v1/router-lists/catalog`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: ac.signal,
})
if (!res.ok) return
const catalog = await res.json() as {
modules?: { items?: Array<{ id: string; name: string }> }
ip_ranges?: { items?: Array<{ module_id: string; entry: { prefix: string } }> }
asns?: { items?: Array<{ module_id: string; entry: { asn: number } }> }
}
const mods = new Map((catalog.modules?.items ?? []).map((m) => [m.id, m.name]))
const next: CatalogCidr[] = []
for (const item of catalog.ip_ranges?.items ?? []) {
const prefix = String(item.entry?.prefix ?? "").trim()
const purpose = mods.get(item.module_id) ?? ""
const parsed = parseCidrV4(prefix)
if (!prefix || !parsed) continue
next.push({ cidr: prefix, purpose, prefixLen: parsed.prefixLen })
}
next.sort((a, b) => b.prefixLen - a.prefixLen)
const nextAsn = new Map<number, string>()
for (const item of catalog.asns?.items ?? []) {
const purpose = mods.get(item.module_id)
const asn = Number(item.entry?.asn)
if (purpose && Number.isFinite(asn) && asn > 0) nextAsn.set(asn, purpose)
}
cidrs = next
asnPurpose = nextAsn
fetchedAt = Date.now()
} finally {
clearTimeout(t)
}
} catch {
/* catalog optional */
} finally {
inflight = null
}
})()
return inflight
}
/** Background refresh — analytics never awaits the HTTP. */
export function refreshFlowCatalogInBackground(): void {
void fetchCatalog()
}
@@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
const a = {
serverId: 7,
src: "10.1.1.8",
dst: "8.8.8.8",
proto: 6,
srcPort: 1,
dstPort: 443,
inIface: "2",
bytes: 12_000,
packets: 10,
}
const b = { ...a, inIface: "10", bytes: 8_000, packets: 8 }
const out = dedupFlowRowsMaxBytes([a, b])
assert.equal(out.length, 1)
assert.equal(out[0]?.bytes, 12_000)
assert.equal(out[0]?.inIface, "2")
assert.equal(flowTupleKey(a), flowTupleKey(b))
const sameIface = dedupFlowRowsMaxBytes([a, { ...a, bytes: 3_000, packets: 2 }])
assert.equal(sameIface[0]?.bytes, 15_000)
console.log("traffic-flow-dedup.test.ts: ok")
@@ -0,0 +1,44 @@
export interface FlowTupleRow {
serverId: number
src: string
dst: string
proto: number
srcPort: number
dstPort: number
inIface: string
bytes: number
packets: number
}
export function flowTupleKey(r: Pick<FlowTupleRow, "serverId" | "src" | "dst" | "proto" | "srcPort" | "dstPort">): string {
return `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}`
}
function ifaceKey(r: FlowTupleRow): string {
return `${flowTupleKey(r)}|${r.inIface}`
}
/**
* Один 5-tuple на двух ifIndex это один поток: сначала сумма по бакетам/iface,
* затем max байт между интерфейсами (не sum).
*/
export function dedupFlowRowsMaxBytes<T extends FlowTupleRow>(rows: T[]): T[] {
const byIface = new Map<string, T>()
for (const row of rows) {
const key = ifaceKey(row)
const prev = byIface.get(key)
if (!prev) {
byIface.set(key, { ...row })
continue
}
prev.bytes += row.bytes
prev.packets += row.packets
}
const byTuple = new Map<string, T>()
for (const row of byIface.values()) {
const key = flowTupleKey(row)
const prev = byTuple.get(key)
if (!prev || row.bytes > prev.bytes) byTuple.set(key, row)
}
return [...byTuple.values()]
}
@@ -0,0 +1,113 @@
import { generateNativeConf } from "./wireguard-config.js"
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
const COMPOSE_DIR = "/opt/cdn-mm"
export function buildHostWgQuickConf(): string {
const row = getTrafficFlowSettingsRow()
const peers = listHostPeers()
return generateNativeConf({
name: "wg-flow",
mtu: 1420,
privateKey: row.hostPrivateKey || undefined,
address: `${row.collectorIp}/24`,
comment: "MikrotikManager traffic-flow collector",
peers: peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedIps,
comment: p.name,
endpoint: p.endpoint,
persistentKeepalive: p.endpoint ? 25 : undefined,
})),
})
}
export function buildHostComposeOverride(): string {
const row = getTrafficFlowSettingsRow()
return [
"# Docker Compose merge для /opt/cdn-mm",
"# Не править docker-compose.yml. Traefik не трогать.",
"# Сначала: wg-quick up wg-flow (адрес " + row.collectorIp + ")",
"# затем: docker compose up -d --force-recreate backend",
"# Docker userland-proxy может SNAT UDP source в 172.x — ingest сопоставит единственный JH.",
"",
"services:",
" backend:",
" environment:",
" FLOW_LISTEN_HOST: \"0.0.0.0\"",
" ports:",
` - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"`,
"",
].join("\n")
}
export function buildHostLinuxInstallSh(): string {
const row = getTrafficFlowSettingsRow()
const conf = buildHostWgQuickConf().replace(/\s+$/, "") + "\n"
const override = buildHostComposeOverride()
const collector = row.collectorIp
const flowPort = row.flowListenPort
return `#!/usr/bin/env bash
# WG-клиент на хосте /opt/cdn-mm JH:13232, IPFIX в контейнер backend.
# Запуск: sudo bash install-wg-flow.sh
set -euo pipefail
if [[ \${EUID:-$(id -u)} -ne 0 ]]; then
echo "Запустите от root: sudo bash $0" >&2
exit 1
fi
COLLECTOR_IP="${collector}"
FLOW_PORT="${flowPort}"
COMPOSE_DIR="${COMPOSE_DIR}"
if ! command -v wg >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard
fi
install -d -m 700 /etc/wireguard
cat > /etc/wireguard/wg-flow.conf <<'WGEOF'
${conf}WGEOF
chmod 600 /etc/wireguard/wg-flow.conf
systemctl enable --now wg-quick@wg-flow
echo "=== wg show wg-flow ==="
wg show wg-flow
echo "=== адрес (ожидаем \${COLLECTOR_IP}/24) ==="
ip -4 addr show dev wg-flow
if [[ ! -d "\$COMPOSE_DIR" ]]; then
echo "Нет \$COMPOSE_DIR — положите override.yml туда вручную (вкладка compose)." >&2
exit 1
fi
cat > "\$COMPOSE_DIR/docker-compose.override.yml" <<'OVEOF'
${override}OVEOF
cd "\$COMPOSE_DIR"
docker compose up -d --force-recreate backend
echo "=== UDP \${FLOW_PORT} на хосте (ожидаем \${COLLECTOR_IP}:\${FLOW_PORT} docker-proxy) ==="
ss -ulnp | grep -E "\${FLOW_PORT}" || true
echo "=== PortBindings mmapp-backend ==="
docker inspect -f '{{json .HostConfig.PortBindings}}' mmapp-backend
echo "=== handshake (keepalive 25s к JH:13232) ==="
wg show wg-flow
# nft на хосте MM не трогаем. Bind только на COLLECTOR_IP, не 0.0.0.0.
# Если backend стартовал до wg-flow: docker compose up -d --force-recreate backend
echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только на \${COLLECTOR_IP}, не на 0.0.0.0."
`
}
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
return [
{ id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: buildHostLinuxInstallSh() },
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
{ id: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() },
]
}
@@ -0,0 +1,51 @@
import assert from "node:assert/strict"
import {
rememberServerIfaces,
resetIfaceCacheForTests,
resolveIfaceName,
rosIdToIfIndex,
} from "./traffic-flow-ifindex.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
assert.equal(rosIdToIfIndex("*A"), 10)
assert.equal(rosIdToIfIndex("*D"), 13)
assert.equal(rosIdToIfIndex("*2"), 2)
assert.equal(rosIdToIfIndex("*9"), 9)
assert.equal(rosIdToIfIndex("0"), 0)
assert.equal(rosIdToIfIndex(""), null)
resetIfaceCacheForTests()
rememberServerIfaces(7, [
{ ".id": "*2", name: "ether1" },
{ ".id": "*A", name: "wg-flow" },
{ ".id": "*D", name: "bridge" },
])
assert.equal(resolveIfaceName(7, "2").name, "ether1")
assert.equal(resolveIfaceName(7, "10").name, "wg-flow")
assert.equal(resolveIfaceName(7, "13").name, "bridge")
assert.equal(resolveIfaceName(7, "0").name, "—")
assert.equal(resolveIfaceName(7, "ether1").name, "ether1")
assert.equal(resolveIfaceName(7, "99").name, "#99")
resetIfaceCacheForTests()
rememberServerIfaces(8, [
{ ifindex: "10", ".id": "*12", name: "gre1" },
])
assert.equal(rosIdToIfIndex("*12"), 18)
assert.equal(resolveIfaceName(8, "10").name, "gre1")
assert.equal(resolveIfaceName(8, "18").name, "gre1")
assert.equal(applicationName(6, 443), "HTTPS")
assert.equal(applicationName(17, 53), "DNS")
assert.equal(applicationName(6, 22), "SSH")
assert.equal(applicationName(17, 51820), "WireGuard")
assert.equal(applicationName(6, 179), "BGP")
const allow = new Map<number, Set<string>>([[7, new Set(["ether1", "wg-flow"])]])
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", {}, allow), true)
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "bridge", {}, allow), false)
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", { iface: "ether1" }, allow), true)
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", { iface: "wg-flow" }, allow), false)
assert.equal(flowRowMatchesFilter({ serverId: 8, inIface: "2" }, "ether1", { serverId: 7 }, null), false)
console.log("traffic-flow-ifaces.test.ts: ok")
@@ -0,0 +1,37 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import {
ifaceCacheFresh,
rememberServerIfaces,
type RosIfaceIndexRow,
} from "./traffic-flow-ifindex.js"
export {
ifaceCacheFresh,
ifaceCacheHas,
rememberServerIfaces,
resetIfaceCacheForTests,
resolveIfaceName,
rosIdToIfIndex,
} from "./traffic-flow-ifindex.js"
const inflight = new Set<number>()
export async function refreshServerIfaces(serverId: number, force = false): Promise<void> {
if (inflight.has(serverId)) return
if (!force && ifaceCacheFresh(serverId)) return
inflight.add(serverId)
try {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!row) return
const client = MikrotikClient.fromServer(row)
const ifaces = await client.get<RosIfaceIndexRow[]>("/interface")
rememberServerIfaces(serverId, Array.isArray(ifaces) ? ifaces : [])
} catch {
/* keep previous cache */
} finally {
inflight.delete(serverId)
}
}
@@ -0,0 +1,58 @@
export interface RosIfaceIndexRow {
".id"?: string
name?: string
ifindex?: string
}
const cache = new Map<number, Map<number, string>>()
const fetchedAt = new Map<number, number>()
export const IFACE_CACHE_TTL_MS = 60_000
/** RouterOS `.id` (`*A`) → SNMP ifIndex (10). */
export function rosIdToIfIndex(id: string | undefined | null): number | null {
if (!id) return null
const raw = String(id).trim()
const hex = raw.startsWith("*") ? raw.slice(1) : raw
if (!hex || !/^[0-9a-fA-F]+$/.test(hex)) return null
const n = parseInt(hex, 16)
return Number.isFinite(n) ? n : null
}
export function rememberServerIfaces(serverId: number, rows: RosIfaceIndexRow[]): void {
const map = new Map<number, string>()
for (const row of rows) {
const name = String(row.name ?? "").trim()
if (!name) continue
const fromProp = Number.parseInt(String(row.ifindex ?? ""), 10)
const fromId = rosIdToIfIndex(row[".id"])
if (Number.isFinite(fromProp) && fromProp > 0) map.set(fromProp, name)
if (fromId != null && fromId > 0) map.set(fromId, name)
}
cache.set(serverId, map)
fetchedAt.set(serverId, Date.now())
}
export function resolveIfaceName(serverId: number, indexOrName: string): { name: string; index: string } {
const trimmed = String(indexOrName ?? "").trim()
if (!trimmed || trimmed === "0") return { name: "—", index: trimmed }
if (!/^\d+$/.test(trimmed)) return { name: trimmed, index: "" }
const idx = Number(trimmed)
const name = cache.get(serverId)?.get(idx)
if (name) return { name, index: trimmed }
return { name: `#${trimmed}`, index: trimmed }
}
export function ifaceCacheHas(serverId: number): boolean {
return cache.has(serverId)
}
export function ifaceCacheFresh(serverId: number, ttlMs = IFACE_CACHE_TTL_MS): boolean {
const prev = fetchedAt.get(serverId) ?? 0
return Boolean(prev && Date.now() - prev < ttlMs && cache.has(serverId))
}
export function resetIfaceCacheForTests(): void {
cache.clear()
fetchedAt.clear()
}
+451
View File
@@ -0,0 +1,451 @@
import { createSocket, type Socket } from "node:dgram"
import { desc, eq, gte, sql } from "drizzle-orm"
import { db } from "../db/index.js"
import { flowBuckets, servers } from "../db/schema.js"
import type { FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { parseFlowPacket, protoName, type ParsedFlow } from "./traffic-flow-parse.js"
import { pickServerIdForExporter } from "./traffic-flow-map-exporter.js"
import {
getTrafficFlowSettingsRow,
listHostPeers,
recordFlowListenerError,
recordFlowPacket,
} from "./traffic-flow-settings.js"
import { ifaceCacheFresh, refreshServerIfaces, resolveIfaceName } from "./traffic-flow-ifaces.js"
import { applicationName } from "./traffic-flow-apps.js"
export interface FlowListenerState {
bound: boolean
address: string | null
}
export interface PendingFlowRow {
serverId: number
bucketAt: string
src: string
dst: string
proto: number
srcPort: number
dstPort: number
bytes: number
packets: number
inIface: string
outIface: string
}
const TICK_MS = 2_000
const RING_LEN = 60
let socket: Socket | null = null
let state: FlowListenerState = { bound: false, address: null }
const pending = new Map<string, {
serverId: number
bucketAt: string
flow: ParsedFlow
bytes: number
packets: number
}>()
let flushTimer: ReturnType<typeof setInterval> | null = null
const tickAccum = new Map<string, { inBytes: number; outBytes: number }>()
const rings = new Map<string, { inBps: number[]; outBps: number[] }>()
export function getFlowListenerState(): FlowListenerState {
return state
}
function minuteBucketIso(at = Date.now()): string {
const d = new Date(at)
d.setSeconds(0, 0)
return d.toISOString()
}
function ringKey(serverId: number, iface: string): string {
return `${serverId}\0${iface || "__all__"}`
}
function bumpTick(key: string, inBytes: number, outBytes: number): void {
const prev = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
prev.inBytes += inBytes
prev.outBytes += outBytes
tickAccum.set(key, prev)
}
function addToTick(serverId: number, inIface: string, outIface: string, bytes: number): void {
bumpTick(ringKey(serverId, "__all__"), bytes, 0)
if (inIface) bumpTick(ringKey(serverId, inIface), bytes, 0)
if (outIface && outIface !== inIface) bumpTick(ringKey(serverId, outIface), 0, bytes)
}
function emptyRing(): { inBps: number[]; outBps: number[] } {
return { inBps: Array(RING_LEN).fill(0), outBps: Array(RING_LEN).fill(0) }
}
export function rollFlowRings(): void {
const keys = new Set([...tickAccum.keys(), ...rings.keys()])
const sec = TICK_MS / 1000
for (const key of keys) {
const acc = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
tickAccum.delete(key)
const inBps = (acc.inBytes * 8) / sec
const outBps = (acc.outBytes * 8) / sec
let ring = rings.get(key)
if (!ring) {
ring = emptyRing()
rings.set(key, ring)
}
ring.inBps.push(inBps)
ring.inBps.shift()
ring.outBps.push(outBps)
ring.outBps.shift()
}
}
export function getRingMbps(serverId: number, iface = "__all__"): {
rx: number[]
tx: number[]
rxNow: number
txNow: number
} {
const ring = rings.get(ringKey(serverId, iface))
const scale = 1_000_000
if (!ring) {
return { rx: Array(RING_LEN).fill(0), tx: Array(RING_LEN).fill(0), rxNow: 0, txNow: 0 }
}
return {
rx: ring.inBps.map((b) => b / scale),
tx: ring.outBps.map((b) => b / scale),
rxNow: (ring.inBps[RING_LEN - 1] ?? 0) / scale,
txNow: (ring.outBps[RING_LEN - 1] ?? 0) / scale,
}
}
function resolveServerId(exporterIp: string): number | null {
const settings = getTrafficFlowSettingsRow()
const rows = db.select({
id: servers.id,
host: servers.host,
mgmtTunnelIp: servers.mgmtTunnelIp,
}).from(servers).all()
const byTunnelIp = new Map<string, number>()
const hostIps = new Map<string, number>()
for (const row of rows) {
if (row.mgmtTunnelIp) byTunnelIp.set(row.mgmtTunnelIp, row.id)
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(row.host)) hostIps.set(row.host, row.id)
}
return pickServerIdForExporter({
exporterIp,
overlayPrefix: settings.prefix,
byTunnelIp,
peers: listHostPeers(),
hostIps,
})
}
function queueFlows(exporterIp: string, flows: ParsedFlow[]): boolean {
const serverId = resolveServerId(exporterIp)
if (serverId == null) return false
const needsRefresh = !ifaceCacheFresh(serverId)
|| flows.some((f) => resolveIfaceName(serverId, f.inIface).name.startsWith("#"))
if (needsRefresh) void refreshServerIfaces(serverId, true)
const bucketAt = minuteBucketIso()
for (const flow of flows) {
addToTick(serverId, flow.inIface, flow.outIface, flow.bytes)
const key = `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}\0${flow.inIface}`
const prev = pending.get(key)
if (prev) {
prev.bytes += flow.bytes
prev.packets += flow.packets
} else {
pending.set(key, {
serverId,
bucketAt,
flow: { ...flow },
bytes: flow.bytes,
packets: flow.packets,
})
}
}
return true
}
export function peekPendingFlows(): PendingFlowRow[] {
return [...pending.values()].map((row) => ({
serverId: row.serverId,
bucketAt: row.bucketAt,
src: row.flow.src || "0.0.0.0",
dst: row.flow.dst || "0.0.0.0",
proto: row.flow.proto,
srcPort: row.flow.srcPort,
dstPort: row.flow.dstPort,
bytes: row.bytes,
packets: row.packets,
inIface: row.flow.inIface,
outIface: row.flow.outIface,
}))
}
function flushPending() {
if (pending.size === 0) return
const settings = getTrafficFlowSettingsRow()
const topN = Math.max(20, settings.topN)
const cutoff = new Date(Date.now() - settings.retentionHours * 3600_000).toISOString()
const rows = [...pending.values()]
pending.clear()
for (const row of rows) {
try {
db.insert(flowBuckets).values({
serverId: row.serverId,
bucketAt: row.bucketAt,
src: row.flow.src || "0.0.0.0",
dst: row.flow.dst || "0.0.0.0",
proto: row.flow.proto,
srcPort: row.flow.srcPort,
dstPort: row.flow.dstPort,
bytes: row.bytes,
packets: row.packets,
inIface: row.flow.inIface,
}).onConflictDoUpdate({
target: [
flowBuckets.serverId,
flowBuckets.bucketAt,
flowBuckets.src,
flowBuckets.dst,
flowBuckets.proto,
flowBuckets.srcPort,
flowBuckets.dstPort,
flowBuckets.inIface,
],
set: {
bytes: sql`${flowBuckets.bytes} + excluded.bytes`,
packets: sql`${flowBuckets.packets} + excluded.packets`,
},
}).run()
} catch {
// ignore single-row failures
}
}
db.delete(flowBuckets).where(sql`${flowBuckets.bucketAt} < ${cutoff}`).run()
const latest = db.select({ bucketAt: flowBuckets.bucketAt }).from(flowBuckets)
.orderBy(desc(flowBuckets.bucketAt)).limit(1).all()[0]?.bucketAt
if (!latest) return
const latestRows = db.select().from(flowBuckets).where(eq(flowBuckets.bucketAt, latest)).all()
const byServer = new Map<number, typeof latestRows>()
for (const r of latestRows) {
const list = byServer.get(r.serverId) ?? []
list.push(r)
byServer.set(r.serverId, list)
}
for (const list of byServer.values()) {
if (list.length <= topN) continue
list.sort((a, b) => b.bytes - a.bytes)
for (const d of list.slice(topN)) {
db.delete(flowBuckets).where(eq(flowBuckets.id, d.id)).run()
}
}
}
function onTick() {
rollFlowRings()
flushPending()
}
function onMessage(msg: Buffer, rinfo: { address: string }) {
try {
const flows = parseFlowPacket(msg, rinfo.address)
recordFlowPacket(rinfo.address)
if (!flows.length) return
if (!queueFlows(rinfo.address, flows)) {
recordFlowListenerError(
`IPFIX от ${rinfo.address}: нет jump-host с адресом wg-flow. Docker SNAT (172.x) при нескольких JH не различим.`,
)
return
}
recordFlowListenerError("")
} catch (e) {
recordFlowListenerError(e instanceof Error ? e.message : String(e))
}
}
export function stopTrafficFlowListener() {
if (flushTimer) {
clearInterval(flushTimer)
flushTimer = null
}
flushPending()
if (socket) {
try { socket.close() } catch { /* ignore */ }
socket = null
}
state = { bound: false, address: null }
}
export function startTrafficFlowListener() {
stopTrafficFlowListener()
const settings = getTrafficFlowSettingsRow()
if (!settings.enabled) {
state = { bound: false, address: null }
return
}
const host = process.env.FLOW_LISTEN_HOST?.trim() || settings.collectorIp || "127.0.0.1"
const port = settings.flowListenPort
const sock = createSocket("udp4")
sock.on("error", (err) => {
recordFlowListenerError(err.message)
state = { bound: false, address: null }
})
sock.on("message", onMessage)
sock.bind(port, host, () => {
state = { bound: true, address: `${host}:${port}` }
recordFlowListenerError("")
})
socket = sock
flushTimer = setInterval(onTick, TICK_MS)
}
export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] {
const stored = db.select().from(flowBuckets).where(gte(flowBuckets.bucketAt, sinceIso)).all()
const merged = new Map<string, PendingFlowRow>()
for (const r of stored) {
const key = `${r.serverId}|${r.bucketAt}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}|${r.inIface}`
merged.set(key, {
serverId: r.serverId,
bucketAt: r.bucketAt,
src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes: r.bytes,
packets: r.packets,
inIface: r.inIface,
outIface: "",
})
}
for (const p of peekPendingFlows()) {
if (p.bucketAt < sinceIso) continue
const key = `${p.serverId}|${p.bucketAt}|${p.src}|${p.dst}|${p.proto}|${p.srcPort}|${p.dstPort}|${p.inIface}`
const prev = merged.get(key)
if (prev) {
prev.bytes += p.bytes
prev.packets += p.packets
} else {
merged.set(key, { ...p })
}
}
return [...merged.values()]
}
export function listFlowTalkers(minutes = 5): FlowStatsDto {
const settings = getTrafficFlowSettingsRow()
const rangeStart = new Date(Date.now() - minutes * 60_000).toISOString()
const rows = listStoredFlowRows(rangeStart)
const serverRows = db.select().from(servers).all()
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
const protoBytes = new Map<number, number>()
const srcs = new Set<string>()
const dsts = new Set<string>()
const exporters = new Set<number>()
let totalBytes = 0
for (const r of rows) {
const resolved = resolveIfaceName(r.serverId, r.inIface)
const key = `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}|${r.inIface}`
const prev = agg.get(key)
const bytes = r.bytes
totalBytes += bytes
srcs.add(r.src)
dsts.add(r.dst)
exporters.add(r.serverId)
protoBytes.set(r.proto, (protoBytes.get(r.proto) ?? 0) + bytes)
if (prev) {
prev.rawBytes += bytes
prev.bytes += bytes
prev.packets += r.packets
} else {
agg.set(key, {
serverId: String(r.serverId),
serverName: nameById.get(r.serverId) ?? String(r.serverId),
src: r.src,
dst: r.dst,
proto: r.proto,
protoName: protoName(r.proto),
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes,
packets: r.packets,
bps: 0,
inIface: resolved.name,
inIfaceIndex: resolved.index,
application: applicationName(r.proto, r.dstPort, r.srcPort),
rawBytes: bytes,
})
}
}
const windowSec = Math.max(60, minutes * 60)
const talkers = [...agg.values()]
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
.sort((a, b) => b.bytes - a.bytes)
.slice(0, settings.topN)
.map(({ rawBytes: _raw, ...rest }) => rest)
let topProto = "—"
let topProtoBytes = 0
for (const [p, b] of protoBytes) {
if (b > topProtoBytes) {
topProtoBytes = b
topProto = protoName(p)
}
}
return {
exportersOnline: exporters.size,
bytesPerMin: minutes > 0 ? totalBytes / minutes : totalBytes,
uniqueSrc: srcs.size,
uniqueDst: dsts.size,
topProto,
talkers,
lastExporterIp: settings.lastExporterIp ?? null,
lastError: settings.lastError || null,
packetsReceived: settings.packetsReceived,
lastDatagramAt: settings.lastDatagramAt ?? null,
listenerBound: state.bound,
listenerAddress: state.address,
}
}
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlow[]) {
queueFlows(exporterIp, flows)
rollFlowRings()
flushPending()
}
/** Кладёт потоки в pending без flush в SQLite — для юнит-тестов аналитики. */
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlow[]) {
const bucketAt = minuteBucketIso()
for (const flow of flows) {
addToTick(serverId, flow.inIface, flow.outIface, flow.bytes)
const key = `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}\0${flow.inIface}`
const prev = pending.get(key)
if (prev) {
prev.bytes += flow.bytes
prev.packets += flow.packets
} else {
pending.set(key, {
serverId,
bucketAt,
flow: { ...flow },
bytes: flow.bytes,
packets: flow.packets,
})
}
}
rollFlowRings()
}
export function resetFlowRingsForTests() {
tickAccum.clear()
rings.clear()
pending.clear()
}
+54
View File
@@ -0,0 +1,54 @@
/** IPv4 helpers for RIPEstat prefix cache and EvoBGP CIDR match. */
export function ipv4ToInt(ip: string): number | null {
const parts = String(ip ?? "").trim().split(".")
if (parts.length !== 4) return null
let n = 0
for (const p of parts) {
if (!/^\d+$/.test(p)) return null
const o = Number(p)
if (o < 0 || o > 255) return null
n = ((n << 8) >>> 0) + o
}
return n >>> 0
}
export function parseCidrV4(cidr: string): { net: number; mask: number; prefixLen: number } | null {
const raw = String(cidr ?? "").trim()
const [ip, lenRaw] = raw.split("/")
const addr = ipv4ToInt(ip ?? "")
const prefixLen = Number.parseInt(lenRaw ?? "", 10)
if (addr == null || !Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return null
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
return { net: (addr & mask) >>> 0, mask, prefixLen }
}
export function ipInCidrV4(ip: string, cidr: string): boolean {
const addr = ipv4ToInt(ip)
const parsed = parseCidrV4(cidr)
if (addr == null || !parsed) return false
return ((addr & parsed.mask) >>> 0) === parsed.net
}
export function isNonPublicIp(ip: string): boolean {
const trimmed = String(ip ?? "").trim()
if (!trimmed) return true
if (trimmed.includes(":")) {
const lower = trimmed.toLowerCase()
return lower === "::1" || lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd") || lower === "::"
}
const n = ipv4ToInt(trimmed)
if (n == null) return true
const inRange = (cidr: string) => ipInCidrV4(trimmed, cidr)
return (
inRange("0.0.0.0/8")
|| inRange("10.0.0.0/8")
|| inRange("127.0.0.0/8")
|| inRange("169.254.0.0/16")
|| inRange("172.16.0.0/12")
|| inRange("192.168.0.0/16")
|| inRange("100.64.0.0/10")
|| inRange("224.0.0.0/4")
|| inRange("255.255.255.255/32")
)
}
@@ -0,0 +1,54 @@
import assert from "node:assert/strict"
import {
bareIpv4,
ipInCidr,
isNatMasqueradeExporter,
normalizeExporterIp,
pickServerIdForExporter,
} from "./traffic-flow-map-exporter.js"
assert.equal(normalizeExporterIp("::ffff:172.18.0.2"), "172.18.0.2")
assert.equal(bareIpv4("10.255.254.3/32"), "10.255.254.3")
assert.equal(ipInCidr("10.255.254.3", "10.255.254.0/24"), true)
assert.equal(ipInCidr("172.18.0.2", "10.255.254.0/24"), false)
assert.equal(isNatMasqueradeExporter("172.18.0.2", "10.255.254.0/24"), true)
assert.equal(isNatMasqueradeExporter("10.255.254.3", "10.255.254.0/24"), false)
assert.equal(isNatMasqueradeExporter("10.0.0.12", "10.255.254.0/24"), true)
const byTunnel = new Map([["10.255.254.3", 7]])
assert.equal(pickServerIdForExporter({
exporterIp: "10.255.254.3",
overlayPrefix: "10.255.254.0/24",
byTunnelIp: byTunnel,
peers: [],
hostIps: new Map(),
}), 7)
assert.equal(pickServerIdForExporter({
exporterIp: "172.18.0.2",
overlayPrefix: "10.255.254.0/24",
byTunnelIp: byTunnel,
peers: [{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] }],
hostIps: new Map(),
}), 7)
assert.equal(pickServerIdForExporter({
exporterIp: "172.18.0.2",
overlayPrefix: "10.255.254.0/24",
byTunnelIp: new Map([["10.255.254.3", 7], ["10.255.254.4", 8]]),
peers: [
{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] },
{ serverId: 8, address: "10.255.254.4", allowedIps: ["10.255.254.4/32"] },
],
hostIps: new Map(),
}), null)
assert.equal(pickServerIdForExporter({
exporterIp: "94.142.140.141",
overlayPrefix: "10.255.254.0/24",
byTunnelIp: byTunnel,
peers: [],
hostIps: new Map([["94.142.140.141", 7]]),
}), 7)
console.log("traffic-flow-map-exporter.test.ts: ok")
@@ -0,0 +1,82 @@
export interface OverlayPeerRef {
serverId: number
address: string
allowedIps: string[]
}
export function normalizeExporterIp(ip: string): string {
const trimmed = ip.trim()
if (trimmed.toLowerCase().startsWith("::ffff:")) return trimmed.slice(7)
return trimmed
}
export function bareIpv4(value: string): string {
const raw = normalizeExporterIp(value).split("/")[0]?.trim() ?? ""
return raw
}
function ipv4ToInt(ip: string): number | null {
const parts = ip.split(".")
if (parts.length !== 4) return null
const n = parts.map((x) => Number(x))
if (n.some((x) => !Number.isInteger(x) || x < 0 || x > 255)) return null
return ((n[0]! << 24) | (n[1]! << 16) | (n[2]! << 8) | n[3]!) >>> 0
}
export function ipInCidr(ip: string, cidr: string): boolean {
const host = bareIpv4(ip)
const [base, bitsRaw] = cidr.split("/")
const bits = Number(bitsRaw ?? 32)
const a = ipv4ToInt(host)
const b = ipv4ToInt(bareIpv4(base ?? ""))
if (a == null || b == null || !Number.isFinite(bits) || bits < 0 || bits > 32) return false
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
return (a & mask) === (b & mask)
}
/** Docker userland-proxy / bridge SNAT, не адрес из оверлея wg-flow. */
export function isNatMasqueradeExporter(ip: string, overlayPrefix: string): boolean {
const host = bareIpv4(ip)
if (!host) return false
if (ipInCidr(host, overlayPrefix)) return false
return ipInCidr(host, "10.0.0.0/8")
|| ipInCidr(host, "172.16.0.0/12")
|| ipInCidr(host, "192.168.0.0/16")
|| ipInCidr(host, "127.0.0.0/8")
}
export function pickServerIdForExporter(opts: {
exporterIp: string
overlayPrefix: string
byTunnelIp: Map<string, number>
peers: OverlayPeerRef[]
hostIps: Map<string, number>
}): number | null {
const exporter = bareIpv4(opts.exporterIp)
if (!exporter) return null
const exact = opts.byTunnelIp.get(exporter)
if (exact != null) return exact
for (const [ip, id] of opts.byTunnelIp) {
if (bareIpv4(ip) === exporter) return id
}
for (const peer of opts.peers) {
if (bareIpv4(peer.address) === exporter) return peer.serverId
if (peer.allowedIps.some((cidr) => ipInCidr(exporter, cidr) || bareIpv4(cidr) === exporter)) {
return peer.serverId
}
}
const byHost = opts.hostIps.get(exporter)
if (byHost != null) return byHost
if (!isNatMasqueradeExporter(exporter, opts.overlayPrefix)) return null
const tunnelIds = [...new Set(opts.byTunnelIp.values())]
if (tunnelIds.length === 1) return tunnelIds[0] ?? null
const peerIds = [...new Set(opts.peers.map((p) => p.serverId))]
if (peerIds.length === 1) return peerIds[0] ?? null
return null
}
@@ -0,0 +1,289 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
import { encodeRosId, MikrotikClient, MikrotikError } from "./mikrotik.js"
import { getEnabledServerById, listWireGuardInterfaces } from "./wireguard-live.js"
import {
asRosArray,
patchRosPath,
putIpAddress,
putWireguardInterface,
putWireguardPeer,
rosRowId,
toRosBody,
} from "./wireguard-ros.js"
import {
enableTrafficFlowIngest,
ensureHostKeys,
getTrafficFlowSettingsRow,
upsertHostPeer,
} from "./traffic-flow-settings.js"
import { startTrafficFlowListener } from "./traffic-flow-ingest.js"
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
const IFACE_NAME = "wg-flow"
const JH_LISTEN_PORT = 13232
const WG_INPUT_COMMENT = "mm-wg-flow"
export function allocateOverlayAddress(prefix: string, collectorIp: string, serverId: number, taken: Set<string>): string {
const [base] = prefix.split("/")
const parts = (base ?? "10.255.254.0").split(".").map((n) => Number.parseInt(n, 10))
const a = parts[0] || 10
const b = parts[1] || 255
const c = parts[2] || 254
const preferredLast = 2 + ((serverId - 1) % 250)
const candidates = [preferredLast, ...Array.from({ length: 253 }, (_, i) => 2 + ((preferredLast - 2 + i) % 253))]
for (const last of candidates) {
const ip = `${a}.${b}.${c}.${last}`
if (ip === collectorIp) continue
if (taken.has(ip)) continue
return ip
}
throw new Error("Нет свободных адресов в префиксе wg-flow")
}
function linuxPeerBlock(publicKey: string, address: string, comment: string, endpoint: string): string {
return [
`[Peer]`,
`PublicKey = ${publicKey}`,
`AllowedIPs = ${address}/32`,
`Endpoint = ${endpoint}:${JH_LISTEN_PORT}`,
`PersistentKeepalive = 25`,
comment ? `# ${comment}` : "",
].filter(Boolean).join("\n")
}
async function findIface(client: MikrotikClient, name: string): Promise<Record<string, unknown> | undefined> {
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard"))
return list.find((i) => String(i.name ?? "") === name)
}
async function findPeer(
client: MikrotikClient,
iface: string,
publicKey: string,
): Promise<Record<string, unknown> | undefined> {
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard/peers"))
return list.find((p) =>
String(p.interface ?? "") === iface && String(p["public-key"] ?? "") === publicKey,
)
}
async function findAddress(client: MikrotikClient, iface: string): Promise<Record<string, unknown> | undefined> {
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/address"))
return list.find((a) => String(a.interface ?? "") === iface)
}
async function findRoute(client: MikrotikClient, dst: string): Promise<Record<string, unknown> | undefined> {
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/route"))
return list.find((r) => String(r["dst-address"] ?? "") === dst)
}
async function ensureWgInputAccept(client: MikrotikClient, listenPort: number): Promise<boolean> {
const rules = asRosArray<Record<string, unknown>>(await client.get("/ip/firewall/filter"))
const existing = rules.find((r) => String(r.comment ?? "") === WG_INPUT_COMMENT)
if (existing) return false
await client.put("/ip/firewall/filter", toRosBody({
chain: "input",
protocol: "udp",
"dst-port": String(listenPort),
action: "accept",
comment: WG_INPUT_COMMENT,
}))
return true
}
/** Официальный авто-source UDP IPFIX, не фильтр 0.0.0.0/0. */
export const FLOW_TARGET_SRC_AUTO = "0.0.0.0"
async function ensureTrafficFlow(
client: MikrotikClient,
collectorIp: string,
port: number,
): Promise<void> {
const body = toRosBody({
enabled: "yes",
interfaces: "all",
"active-flow-timeout": "1m",
"inactive-flow-timeout": "15s",
})
const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow"))
const id = rows[0] ? rosRowId(rows[0]) : ""
if (id) {
await patchRosPath(client, `/ip/traffic-flow/${encodeRosId(id)}`, body)
} else {
await client.post("/ip/traffic-flow/set", body)
}
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
const targetBody = toRosBody({
"dst-address": collectorIp,
"src-address": FLOW_TARGET_SRC_AUTO,
port: String(port),
version: "ipfix",
})
if (existing) {
const targetId = rosRowId(existing)
if (targetId) await patchRosPath(client, `/ip/traffic-flow/target/${encodeRosId(targetId)}`, targetBody)
return
}
await client.put("/ip/traffic-flow/target", targetBody)
}
export function usablePublicHost(raw: string | undefined): string {
if (!raw) return ""
const host = raw.split(",")[0]?.trim().replace(/^\[/, "").replace(/\]:\d+$/, "").split(":")[0]?.trim() ?? ""
const lower = host.toLowerCase()
if (!host) return ""
if (lower === "localhost" || lower === "127.0.0.1" || lower === "::1" || lower === "0.0.0.0") return ""
if (lower.endsWith(".local") || lower.endsWith(".internal") || lower.endsWith(".lan")) return ""
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return ""
return host
}
export async function applyFlowOverlay(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string },
): Promise<TrafficFlowOverlayResult> {
const steps: string[] = []
const keys = ensureHostKeys()
const settings = getTrafficFlowSettingsRow()
const hostPublicKey = settings.hostPublicKey || keys.publicKey
if (!hostPublicKey) {
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
}
const server = getEnabledServerById(String(serverIdRaw))
if (!server || !server.enabled) {
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
}
const endpointHost = (opts?.publicEndpoint?.trim() || server.host.trim()).trim()
if (!endpointHost) {
throw Object.assign(new Error("Укажите публичный IP или DNS jump-host"), { statusCode: 400 })
}
const peerEndpoint = `${endpointHost}:${JH_LISTEN_PORT}`
const taken = new Set(
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
.map((r) => r.ip)
.filter(Boolean),
)
const address = server.mgmtTunnelIp || allocateOverlayAddress(settings.prefix, settings.collectorIp, server.id, taken)
const client = MikrotikClient.fromServer(server)
try {
let iface = await findIface(client, IFACE_NAME)
if (!iface) {
await putWireguardInterface(client, {
name: IFACE_NAME,
"listen-port": String(JH_LISTEN_PORT),
mtu: "1420",
comment: "MikrotikManager traffic-flow overlay",
})
steps.push(`Создан интерфейс ${IFACE_NAME}`)
iface = await findIface(client, IFACE_NAME)
} else {
steps.push(`Интерфейс ${IFACE_NAME} уже есть`)
}
const addrRow = await findAddress(client, IFACE_NAME)
const mask = (settings.prefix.split("/")[1] || "24").replace(/\D/g, "") || "24"
const cidr = `${address}/${mask}`
if (!addrRow) {
await putIpAddress(client, cidr, IFACE_NAME)
steps.push(`Адрес ${cidr}`)
} else {
steps.push(`Адрес на ${IFACE_NAME} уже назначен`)
}
const peer = await findPeer(client, IFACE_NAME, hostPublicKey)
const peerBody = {
interface: IFACE_NAME,
"public-key": hostPublicKey,
"allowed-address": `${settings.collectorIp}/32`,
comment: "MM traffic-flow collector",
name: "mm-collector",
}
if (!peer) {
await putWireguardPeer(client, peerBody)
steps.push("Добавлен пир на pubkey хоста MM (сервер, без endpoint)")
} else {
const id = rosRowId(peer)
const hadEndpoint = Boolean(String(peer["endpoint-address"] ?? "").trim())
if (hadEndpoint && id) {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(id)}`)
await putWireguardPeer(client, peerBody)
steps.push("Пир пересоздан как сервер (endpoint снят)")
} else if (id) {
await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody)
steps.push("Пир хоста MM обновлён")
}
}
const routeDst = `${settings.collectorIp}/32`
const route = await findRoute(client, routeDst)
if (!route) {
await client.put("/ip/route", toRosBody({
"dst-address": routeDst,
gateway: IFACE_NAME,
comment: "MM traffic-flow collector",
}))
steps.push(`Маршрут ${routeDst} через ${IFACE_NAME}`)
} else {
steps.push("Маршрут до collector уже есть")
}
if (await ensureWgInputAccept(client, JH_LISTEN_PORT)) {
steps.push(`Firewall input accept UDP ${JH_LISTEN_PORT}`)
} else {
steps.push("Firewall input WG уже есть")
}
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto)`)
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
const publicKey = created?.publicKey ?? ""
if (!publicKey) {
throw new Error("Не удалось прочитать public-key интерфейса wg-flow")
}
db.update(servers).set({
mgmtTunnelIp: address,
updatedAt: new Date().toISOString(),
}).where(eq(servers.id, server.id)).run()
upsertHostPeer({
serverId: server.id,
name: server.name || server.host,
publicKey,
allowedIps: [`${address}/32`],
address,
endpoint: peerEndpoint,
})
enableTrafficFlowIngest()
startTrafficFlowListener()
steps.push("Коллектор IPFIX на MM включён")
return {
ok: true,
serverId: server.id,
interfaceName: IFACE_NAME,
address,
publicKey,
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
trafficFlow: true,
steps,
hostFiles: listTrafficFlowHostFiles(),
}
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
const err = Object.assign(new Error(`RouterOS: ${msg}`), { statusCode: 502 })
throw err
}
}
@@ -0,0 +1,103 @@
import assert from "node:assert/strict"
import { parseFlowPacket, protoName, resetFlowTemplatesForTests } from "./traffic-flow-parse.js"
import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
function netflowV5One(): Buffer {
const buf = Buffer.alloc(24 + 48)
buf.writeUInt16BE(5, 0)
buf.writeUInt16BE(1, 2)
buf[24] = 10; buf[25] = 1; buf[26] = 1; buf[27] = 8
buf[28] = 8; buf[29] = 8; buf[30] = 8; buf[31] = 8
buf.writeUInt16BE(1, 24 + 12)
buf.writeUInt32BE(10, 24 + 16)
buf.writeUInt32BE(1500, 24 + 20)
buf.writeUInt16BE(443, 24 + 32)
buf.writeUInt16BE(443, 24 + 34)
buf.writeUInt8(6, 24 + 38)
return buf
}
resetFlowTemplatesForTests()
const flows = parseFlowPacket(netflowV5One(), "10.255.254.5")
assert.equal(flows.length, 1)
assert.equal(flows[0]?.src, "10.1.1.8")
assert.equal(flows[0]?.dst, "8.8.8.8")
assert.equal(flows[0]?.proto, 6)
assert.equal(flows[0]?.bytes, 1500)
assert.equal(flows[0]?.inIface, "1")
assert.equal(protoName(6), "TCP")
assert.equal(parseFlowPacket(Buffer.from([0, 1]), "1.1.1.1").length, 0)
const taken = new Set(["10.255.254.2"])
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 1, taken), "10.255.254.3")
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3")
assert.equal(usablePublicHost("localhost:8000"), "")
assert.equal(usablePublicHost("127.0.0.1"), "")
assert.equal(usablePublicHost("192.168.1.10"), "")
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
assert.equal(FLOW_TARGET_SRC_AUTO, "0.0.0.0")
resetFlowTemplatesForTests()
{
const tpl = Buffer.alloc(16 + 16 + 20)
tpl.writeUInt16BE(10, 0)
tpl.writeUInt16BE(tpl.length, 2)
tpl.writeUInt16BE(2, 16)
tpl.writeUInt16BE(16, 18)
tpl.writeUInt16BE(256, 20)
tpl.writeUInt16BE(2, 22)
tpl.writeUInt16BE(8, 24)
tpl.writeUInt16BE(4, 26)
tpl.writeUInt16BE(12, 28)
tpl.writeUInt16BE(4, 30)
const data = Buffer.alloc(16 + 12)
data.writeUInt16BE(10, 0)
data.writeUInt16BE(data.length, 2)
data.writeUInt16BE(256, 16)
data.writeUInt16BE(12, 18)
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
const fromTpl = parseFlowPacket(tpl, "172.18.0.2")
assert.equal(fromTpl.length, 0)
const fromData = parseFlowPacket(data, "172.18.0.2")
assert.equal(fromData.length, 1)
assert.equal(fromData[0]?.src, "10.1.1.8")
assert.equal(fromData[0]?.dst, "8.8.8.8")
}
resetFlowTemplatesForTests()
{
const tpl = Buffer.alloc(16 + 24)
tpl.writeUInt16BE(10, 0)
tpl.writeUInt16BE(tpl.length, 2)
tpl.writeUInt16BE(2, 16)
tpl.writeUInt16BE(24, 18)
tpl.writeUInt16BE(256, 20)
tpl.writeUInt16BE(4, 22)
tpl.writeUInt16BE(8, 24)
tpl.writeUInt16BE(4, 26)
tpl.writeUInt16BE(12, 28)
tpl.writeUInt16BE(4, 30)
tpl.writeUInt16BE(10, 32)
tpl.writeUInt16BE(4, 34)
tpl.writeUInt16BE(82, 36)
tpl.writeUInt16BE(6, 38)
const data = Buffer.alloc(16 + 22)
data.writeUInt16BE(10, 0)
data.writeUInt16BE(data.length, 2)
data.writeUInt16BE(256, 16)
data.writeUInt16BE(22, 18)
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
data.writeUInt32BE(13, 28)
data.write("ether1", 32)
parseFlowPacket(tpl, "10.255.254.3")
const named = parseFlowPacket(data, "10.255.254.3")
assert.equal(named.length, 1)
assert.equal(named[0]?.inIface, "ether1")
assert.equal(named[0]?.src, "10.1.1.8")
}
console.log("traffic-flow-parse.test.ts: ok")
+310
View File
@@ -0,0 +1,310 @@
export interface ParsedFlow {
src: string
dst: string
proto: number
srcPort: number
dstPort: number
bytes: number
packets: number
inIface: string
outIface: string
}
interface FieldSpec {
type: number
length: number
}
interface Template {
fields: FieldSpec[]
}
const templatesByExporter = new Map<string, Map<number, Template>>()
function ipv4(buf: Buffer, offset: number): string {
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
}
function ipv6(buf: Buffer, offset: number): string {
const parts: string[] = []
for (let i = 0; i < 8; i++) parts.push(buf.readUInt16BE(offset + i * 2).toString(16))
return parts.join(":")
}
const VAR_LEN = 0xffff
function consumeField(
buf: Buffer,
off: number,
length: number,
limit: number,
): { data: Buffer; next: number } | null {
if (length === VAR_LEN) {
if (off >= limit) return null
const first = buf[off]!
if (first < 255) {
const end = off + 1 + first
if (end > limit) return null
return { data: buf.subarray(off + 1, end), next: end }
}
if (off + 3 > limit) return null
const len = buf.readUInt16BE(off + 1)
const end = off + 3 + len
if (end > limit) return null
return { data: buf.subarray(off + 3, end), next: end }
}
const end = off + length
if (end > limit) return null
return { data: buf.subarray(off, end), next: end }
}
function fixedRecordSize(fields: FieldSpec[]): number | null {
let n = 0
for (const f of fields) {
if (f.length === VAR_LEN) return null
n += f.length
}
return n
}
function readUint(buf: Buffer, offset: number, length: number): number {
if (length === 1) return buf.readUInt8(offset)
if (length === 2) return buf.readUInt16BE(offset)
if (length === 4) return buf.readUInt32BE(offset)
if (length === 8) {
const big = buf.readBigUInt64BE(offset)
const n = Number(big)
return Number.isFinite(n) ? n : 0
}
let v = 0
for (let i = 0; i < length; i++) v = (v << 8) + buf[offset + i]
return v >>> 0
}
function parseNetflowV5(buf: Buffer): ParsedFlow[] {
if (buf.length < 24) return []
const count = buf.readUInt16BE(2)
const out: ParsedFlow[] = []
let off = 24
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
out.push({
src: ipv4(buf, off),
dst: ipv4(buf, off + 4),
packets: buf.readUInt32BE(off + 16),
bytes: buf.readUInt32BE(off + 20),
srcPort: buf.readUInt16BE(off + 32),
dstPort: buf.readUInt16BE(off + 34),
proto: buf.readUInt8(off + 38),
inIface: String(buf.readUInt16BE(off + 12)),
outIface: String(buf.readUInt16BE(off + 14)),
})
off += 48
}
return out
}
function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) {
let off = setStart + 4
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
while (off + 4 <= setEnd) {
const templateId = buf.readUInt16BE(off)
const fieldCount = buf.readUInt16BE(off + 2)
off += 4
if (setId === 3) {
// options template: skip scope count
if (off + 2 > setEnd) break
off += 2
}
const fields: FieldSpec[] = []
for (let i = 0; i < fieldCount && off + 4 <= setEnd; i++) {
const type = buf.readUInt16BE(off)
const length = buf.readUInt16BE(off + 2)
off += 4
if (type & 0x8000) {
if (off + 4 > setEnd) break
off += 4
}
fields.push({ type: type & 0x7fff, length })
}
if (templateId >= 256) map.set(templateId, { fields })
}
templatesByExporter.set(exporter, map)
}
function recordFromFields(
fields: FieldSpec[],
buf: Buffer,
offset: number,
limit: number,
): { flow: ParsedFlow; next: number } | null {
let off = offset
let src = ""
let dst = ""
let proto = 0
let srcPort = 0
let dstPort = 0
let bytes = 0
let packets = 0
let inIface = ""
let outIface = ""
let ifaceName = ""
for (const f of fields) {
const field = consumeField(buf, off, f.length, limit)
if (!field) return null
const { data } = field
switch (f.type) {
case 8:
if (data.length === 4) src = ipv4(data, 0)
break
case 12:
if (data.length === 4) dst = ipv4(data, 0)
break
case 27:
if (data.length === 16 && !src) src = ipv6(data, 0)
break
case 28:
if (data.length === 16 && !dst) dst = ipv6(data, 0)
break
case 225:
if (data.length === 4 && !src) src = ipv4(data, 0)
break
case 226:
if (data.length === 4 && !dst) dst = ipv4(data, 0)
break
case 4:
proto = readUint(data, 0, data.length)
break
case 7:
srcPort = readUint(data, 0, data.length)
break
case 11:
dstPort = readUint(data, 0, data.length)
break
case 1:
bytes = readUint(data, 0, data.length)
break
case 2:
packets = readUint(data, 0, data.length)
break
case 85:
if (!bytes) bytes = readUint(data, 0, data.length)
break
case 86:
if (!packets) packets = readUint(data, 0, data.length)
break
case 10:
inIface = String(readUint(data, 0, data.length))
break
case 14:
outIface = String(readUint(data, 0, data.length))
break
case 82:
ifaceName = data.toString("utf8").replace(/\0/g, "").trim()
break
default:
break
}
off = field.next
}
if (ifaceName) inIface = ifaceName
return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface }, next: off }
}
function parseDataRecords(
tpl: Template,
buf: Buffer,
recOff: number,
setEnd: number,
out: ParsedFlow[],
) {
const size = fixedRecordSize(tpl.fields)
while (recOff + 1 < setEnd) {
if (size != null && recOff + size > setEnd) break
const parsed = recordFromFields(tpl.fields, buf, recOff, setEnd)
if (!parsed) break
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
if (parsed.next <= recOff) break
recOff = parsed.next
}
}
function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
if (buf.length < 16) return []
const total = buf.readUInt16BE(2)
const end = Math.min(buf.length, total)
let off = 16
const out: ParsedFlow[] = []
while (off + 4 <= end) {
const setId = buf.readUInt16BE(off)
const setLen = buf.readUInt16BE(off + 2)
if (setLen < 4 || off + setLen > end) break
const setEnd = off + setLen
if (setId === 2 || setId === 3) {
parseIpfixTemplates(exporter, buf, off, setEnd, setId)
} else if (setId >= 256) {
const tpl = templatesByExporter.get(exporter)?.get(setId)
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
}
off = setEnd
}
return out
}
function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
if (buf.length < 20) return []
const count = buf.readUInt16BE(2)
let off = 20
const out: ParsedFlow[] = []
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
for (let s = 0; s < count && off + 4 <= buf.length; s++) {
const setId = buf.readUInt16BE(off)
const setLen = buf.readUInt16BE(off + 2)
if (setLen < 4 || off + setLen > buf.length) break
const setEnd = off + setLen
if (setId === 0) {
let tOff = off + 4
while (tOff + 4 <= setEnd) {
const templateId = buf.readUInt16BE(tOff)
const fieldCount = buf.readUInt16BE(tOff + 2)
tOff += 4
const fields: FieldSpec[] = []
for (let i = 0; i < fieldCount && tOff + 4 <= setEnd; i++) {
fields.push({ type: buf.readUInt16BE(tOff), length: buf.readUInt16BE(tOff + 2) })
tOff += 4
}
if (templateId >= 256) map.set(templateId, { fields })
}
templatesByExporter.set(exporter, map)
} else if (setId >= 256) {
const tpl = map.get(setId)
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
}
off = setEnd
}
return out
}
export function parseFlowPacket(buf: Buffer, exporterIp: string): ParsedFlow[] {
if (buf.length < 2) return []
const version = buf.readUInt16BE(0)
if (version === 5) return parseNetflowV5(buf)
if (version === 9) return parseNetflowV9(buf, exporterIp)
if (version === 10) return parseIpfix(buf, exporterIp)
return []
}
export function protoName(proto: number): string {
switch (proto) {
case 1: return "ICMP"
case 6: return "TCP"
case 17: return "UDP"
case 47: return "GRE"
case 50: return "ESP"
case 89: return "OSPF"
default: return String(proto)
}
}
export function resetFlowTemplatesForTests() {
templatesByExporter.clear()
}
@@ -0,0 +1,72 @@
import assert from "node:assert/strict"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
enqueueRipeMisses,
flushRipeQueueForTests,
lookupRipeCached,
resetRipeCacheForTests,
ripeFetchCountForTests,
seedRipeCacheForTests,
setRipeFetchForTests,
} from "./traffic-flow-ripe.js"
disableRipePersistForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
assert.equal(lookupRipeCached("10.1.1.8")?.ok, false)
assert.equal(lookupRipeCached("192.168.0.1")?.ok, false)
assert.equal(lookupRipeCached("100.64.1.2")?.ok, false)
assert.equal(ripeFetchCountForTests(), 0)
seedRipeCacheForTests({
prefix: "1.2.3.0/24",
asn: 64500,
country: "NL",
lat: 52.3,
lng: 4.9,
holder: "TEST",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(lookupRipeCached("1.2.3.10")?.country, "NL")
assert.equal(lookupRipeCached("1.2.3.10")?.asn, 64500)
assert.equal(ripeFetchCountForTests(), 0)
resetRipeCacheForTests()
disableRipePersistForTests()
setRipeFetchForTests(async (input) => {
const url = String(input)
const body = url.includes("network-info")
? { data: { prefix: "8.8.8.0/24", asns: ["15169"] } }
: url.includes("maxmind-geo-lite")
? { data: { located_resources: [{ locations: [{ country: "US", latitude: 37.4, longitude: -122.1 }] }] } }
: { data: { holder: "GOOGLE" } }
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } })
})
enqueueRipeMisses(["8.8.8.8"])
await flushRipeQueueForTests()
assert.equal(lookupRipeCached("8.8.8.8")?.country, "US")
assert.equal(lookupRipeCached("8.8.8.10")?.prefix, "8.8.8.0/24")
const afterFirst = ripeFetchCountForTests()
assert.ok(afterFirst >= 2)
enqueueRipeMisses(["8.8.8.10"])
await flushRipeQueueForTests()
assert.equal(ripeFetchCountForTests(), afterFirst)
resetRipeCacheForTests()
disableRipePersistForTests()
setRipeFetchForTests(async () => {
throw new Error("timeout")
})
enqueueRipeMisses(["203.0.113.50"])
await flushRipeQueueForTests()
const neg = lookupRipeCached("203.0.113.50")
assert.equal(neg?.ok, false)
const afterNeg = ripeFetchCountForTests()
enqueueRipeMisses(["203.0.113.50"])
await flushRipeQueueForTests()
assert.equal(ripeFetchCountForTests(), afterNeg)
console.log("traffic-flow-ripe.test.ts: ok")
+371
View File
@@ -0,0 +1,371 @@
import { sqliteDatabase } from "../db/index.js"
import { ipInCidrV4, ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
export interface FlowIpMeta {
prefix: string
asn: number
country: string
lat: number | null
lng: number | null
holder: string
ok: boolean
fetchedAt: number
}
const HIT_TTL_MS = 24 * 60 * 60_000
const NEG_TTL_MS = 6 * 60 * 60_000
const MAX_NEW_PREFIX_PER_MIN = 30
const CONCURRENCY = 3
const RIPE_BASE = "https://stat.ripe.net/data"
const UA = "MikrotikManager-flow/1.0"
const mem = new Map<string, FlowIpMeta>()
const asnHolder = new Map<number, { holder: string; fetchedAt: number }>()
const inflight = new Map<string, Promise<FlowIpMeta | null>>()
const queue: string[] = []
const queued = new Set<string>()
const recentFetches: number[] = []
let persistEnabled = true
let enqueueEnabled = true
let loaded = false
let workerRunning = false
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)
let fetchCount = 0
export function disableRipePersistForTests(): void {
persistEnabled = false
}
export function disableRipeEnqueueForTests(): void {
enqueueEnabled = false
}
export function resetRipeCacheForTests(): void {
mem.clear()
asnHolder.clear()
inflight.clear()
queue.length = 0
queued.clear()
recentFetches.length = 0
loaded = persistEnabled ? false : true
workerRunning = false
fetchCount = 0
enqueueEnabled = true
fetchImpl = globalThis.fetch.bind(globalThis)
}
export function seedRipeCacheForTests(entry: FlowIpMeta): void {
mem.set(entry.prefix, { ...entry })
loaded = true
}
export function setRipeFetchForTests(fn: typeof fetch): void {
fetchImpl = fn
fetchCount = 0
}
export function ripeFetchCountForTests(): number {
return fetchCount
}
export async function flushRipeQueueForTests(timeoutMs = 4000): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (!queue.length && !inflight.size && !workerRunning) return
await new Promise((r) => setTimeout(r, 20))
}
}
function ttlMs(ok: boolean): number {
return ok ? HIT_TTL_MS : NEG_TTL_MS
}
function isFresh(entry: FlowIpMeta): boolean {
return Date.now() - entry.fetchedAt < ttlMs(entry.ok)
}
function loadSqlite(): void {
if (loaded || !persistEnabled) {
loaded = true
return
}
loaded = true
try {
const rows = sqliteDatabase.prepare(`
SELECT prefix, asn, country, lat, lng, holder, ok, fetched_at
FROM flow_ip_meta
`).all() as Array<{
prefix: string
asn: number | null
country: string
lat: number | null
lng: number | null
holder: string
ok: number
fetched_at: string
}>
for (const r of rows) {
const fetchedAt = Date.parse(r.fetched_at)
mem.set(r.prefix, {
prefix: r.prefix,
asn: Number(r.asn ?? 0) || 0,
country: r.country || "—",
lat: r.lat == null ? null : Number(r.lat),
lng: r.lng == null ? null : Number(r.lng),
holder: r.holder || "",
ok: r.ok !== 0,
fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0,
})
}
const asns = sqliteDatabase.prepare(`SELECT asn, holder, fetched_at FROM flow_asn_meta`).all() as Array<{
asn: number
holder: string
fetched_at: string
}>
for (const a of asns) {
const fetchedAt = Date.parse(a.fetched_at)
asnHolder.set(a.asn, { holder: a.holder || "", fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0 })
}
} catch {
/* table may not exist in isolated tests */
}
}
function persist(entry: FlowIpMeta): void {
if (!persistEnabled) return
try {
sqliteDatabase.prepare(`
INSERT INTO flow_ip_meta (prefix, asn, country, lat, lng, holder, ok, fetched_at)
VALUES (@prefix, @asn, @country, @lat, @lng, @holder, @ok, @fetchedAt)
ON CONFLICT(prefix) DO UPDATE SET
asn=excluded.asn, country=excluded.country, lat=excluded.lat, lng=excluded.lng,
holder=excluded.holder, ok=excluded.ok, fetched_at=excluded.fetched_at
`).run({
prefix: entry.prefix,
asn: entry.asn,
country: entry.country,
lat: entry.lat,
lng: entry.lng,
holder: entry.holder,
ok: entry.ok ? 1 : 0,
fetchedAt: new Date(entry.fetchedAt).toISOString(),
})
} catch {
/* ignore persist errors */
}
}
function persistAsn(asn: number, holder: string): void {
if (!persistEnabled || !asn) return
try {
sqliteDatabase.prepare(`
INSERT INTO flow_asn_meta (asn, holder, fetched_at)
VALUES (@asn, @holder, @fetchedAt)
ON CONFLICT(asn) DO UPDATE SET holder=excluded.holder, fetched_at=excluded.fetched_at
`).run({
asn,
holder,
fetchedAt: new Date().toISOString(),
})
} catch {
/* ignore */
}
}
function negative(prefix: string): FlowIpMeta {
return {
prefix,
asn: 0,
country: "—",
lat: null,
lng: null,
holder: "",
ok: false,
fetchedAt: Date.now(),
}
}
export function lookupRipeCached(ip: string): FlowIpMeta | null {
loadSqlite()
const trimmed = String(ip ?? "").trim()
if (!trimmed) return null
if (isNonPublicIp(trimmed)) {
return negative(`${trimmed.includes(":") ? trimmed : trimmed}/32`)
}
let best: FlowIpMeta | null = null
let bestLen = -1
for (const entry of mem.values()) {
if (!isFresh(entry)) continue
const parsed = parseCidrV4(entry.prefix)
if (!parsed) continue
if (!ipInCidrV4(trimmed, entry.prefix)) continue
if (parsed.prefixLen > bestLen) {
best = entry
bestLen = parsed.prefixLen
}
}
return best
}
async function ripeJson(path: string, resource: string): Promise<unknown> {
fetchCount += 1
const url = `${RIPE_BASE}/${path}/data.json?resource=${encodeURIComponent(resource)}`
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 12_000)
try {
const res = await fetchImpl(url, {
headers: { Accept: "application/json", "User-Agent": UA },
signal: ac.signal,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} finally {
clearTimeout(t)
}
}
function pickPrefix(data: unknown): string {
const d = data as { data?: { prefix?: string } }
return String(d?.data?.prefix ?? "").trim()
}
function pickAsns(data: unknown): number {
const d = data as { data?: { asns?: unknown } }
const raw = d?.data?.asns
const first = Array.isArray(raw) ? raw[0] : raw
const n = Number.parseInt(String(first ?? "").replace(/^AS/i, ""), 10)
return Number.isFinite(n) ? n : 0
}
function pickGeo(data: unknown): { country: string; lat: number | null; lng: number | null } {
const d = data as {
data?: {
located_resources?: Array<{
locations?: Array<{ country?: string; latitude?: number; longitude?: number }>
}>
}
}
const loc = d?.data?.located_resources?.[0]?.locations?.[0]
const country = String(loc?.country ?? "").trim().toUpperCase()
const lat = loc?.latitude == null ? null : Number(loc.latitude)
const lng = loc?.longitude == null ? null : Number(loc.longitude)
return {
country: country || "—",
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
}
}
function pickHolder(data: unknown): string {
const d = data as { data?: { holder?: string } }
return String(d?.data?.holder ?? "").trim()
}
function allowNewPrefix(): boolean {
const now = Date.now()
while (recentFetches.length && now - recentFetches[0]! > 60_000) recentFetches.shift()
return recentFetches.length < MAX_NEW_PREFIX_PER_MIN
}
async function resolveIp(ip: string): Promise<FlowIpMeta | null> {
const cached = lookupRipeCached(ip)
if (cached) return cached
const pending = inflight.get(ip)
if (pending) return pending
const job = (async () => {
if (!allowNewPrefix()) return null
recentFetches.push(Date.now())
try {
const net = await ripeJson("network-info", ip)
const prefix = pickPrefix(net) || `${ip}/32`
const existing = mem.get(prefix)
if (existing && isFresh(existing)) return existing
const asn = pickAsns(net)
let geo = { country: "—", lat: null as number | null, lng: null as number | null }
try {
geo = pickGeo(await ripeJson("maxmind-geo-lite", prefix))
} catch {
/* best-effort */
}
let holder = asnHolder.get(asn)?.holder ?? ""
if (asn && (!holder || Date.now() - (asnHolder.get(asn)?.fetchedAt ?? 0) > HIT_TTL_MS)) {
try {
holder = pickHolder(await ripeJson("as-overview", `AS${asn}`))
asnHolder.set(asn, { holder, fetchedAt: Date.now() })
persistAsn(asn, holder)
} catch {
/* best-effort */
}
}
const entry: FlowIpMeta = {
prefix,
asn,
country: geo.country,
lat: geo.lat,
lng: geo.lng,
holder,
ok: Boolean(asn || (geo.country && geo.country !== "—")),
fetchedAt: Date.now(),
}
mem.set(prefix, entry)
persist(entry)
return entry
} catch {
const prefix = `${ip}/32`
const entry = negative(prefix)
mem.set(prefix, entry)
persist(entry)
return entry
} finally {
inflight.delete(ip)
}
})()
inflight.set(ip, job)
return job
}
async function runWorker(): Promise<void> {
if (workerRunning) return
workerRunning = true
try {
while (queue.length) {
const batch: string[] = []
while (batch.length < CONCURRENCY && queue.length) {
const ip = queue.shift()
if (!ip) break
queued.delete(ip)
if (lookupRipeCached(ip)) continue
if (ipv4ToInt(ip) == null && !ip.includes(":")) continue
batch.push(ip)
}
if (!batch.length) {
if (!allowNewPrefix()) {
await new Promise((r) => setTimeout(r, 1000))
}
continue
}
await Promise.all(batch.map((ip) => resolveIp(ip)))
}
} finally {
workerRunning = false
if (queue.length) void runWorker()
}
}
/** HTTP / SSE never await this — cache miss is filled on a later tick. */
export function enqueueRipeMisses(ips: Iterable<string>): void {
if (!enqueueEnabled) return
loadSqlite()
for (const raw of ips) {
const ip = String(raw ?? "").trim()
if (!ip || isNonPublicIp(ip)) continue
if (lookupRipeCached(ip)) continue
if (queued.has(ip) || inflight.has(ip)) continue
queued.add(ip)
queue.push(ip)
}
if (queue.length) void runWorker()
}
@@ -0,0 +1,133 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { trafficFlowSettings } from "../db/schema.js"
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
import { generateWireGuardKeyPair } from "./wg-keys.js"
function nowIso() {
return new Date().toISOString()
}
function parsePeers(raw: string): FlowHostPeer[] {
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter((p): p is FlowHostPeer =>
p != null && typeof p === "object" && typeof (p as FlowHostPeer).publicKey === "string",
)
} catch {
return []
}
}
export function getTrafficFlowSettingsRow() {
const row = db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
if (row) return row
const now = nowIso()
db.insert(trafficFlowSettings).values({
id: 1,
enabled: false,
collectorIp: "10.255.254.1",
flowListenPort: 4739,
wgListenPort: 51821,
prefix: "10.255.254.0/24",
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
}
export function toTrafficFlowSettingsDto(
listener: { bound: boolean; address: string | null },
): TrafficFlowSettingsDto {
const row = getTrafficFlowSettingsRow()
return {
enabled: row.enabled,
collectorIp: row.collectorIp,
flowListenPort: row.flowListenPort,
wgListenPort: row.wgListenPort,
prefix: row.prefix,
publicEndpoint: row.publicEndpoint,
hostPublicKey: row.hostPublicKey,
hasHostPrivateKey: Boolean(row.hostPrivateKey),
hubServerId: row.hubServerId ?? null,
retentionHours: row.retentionHours,
topN: row.topN,
lastDatagramAt: row.lastDatagramAt ?? null,
lastExporterIp: row.lastExporterIp ?? null,
lastError: row.lastError || null,
packetsReceived: row.packetsReceived,
listenerBound: listener.bound,
listenerAddress: listener.address,
peers: parsePeers(row.peersJson),
}
}
export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
const row = getTrafficFlowSettingsRow()
db.update(trafficFlowSettings).set({
enabled: patch.enabled ?? row.enabled,
collectorIp: patch.collectorIp ?? row.collectorIp,
flowListenPort: patch.flowListenPort ?? row.flowListenPort,
wgListenPort: patch.wgListenPort ?? row.wgListenPort,
prefix: patch.prefix ?? row.prefix,
publicEndpoint: patch.publicEndpoint ?? row.publicEndpoint,
hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId,
retentionHours: patch.retentionHours ?? row.retentionHours,
topN: patch.topN ?? row.topN,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
return getTrafficFlowSettingsRow()
}
export function ensureHostKeys(): { publicKey: string; created: boolean } {
const row = getTrafficFlowSettingsRow()
if (row.hostPublicKey && row.hostPrivateKey) {
return { publicKey: row.hostPublicKey, created: false }
}
const keys = generateWireGuardKeyPair()
db.update(trafficFlowSettings).set({
hostPublicKey: keys.publicKey,
hostPrivateKey: keys.privateKey,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
return { publicKey: keys.publicKey, created: true }
}
export function upsertHostPeer(peer: FlowHostPeer) {
const row = getTrafficFlowSettingsRow()
const peers = parsePeers(row.peersJson).filter((p) => p.serverId !== peer.serverId)
peers.push(peer)
db.update(trafficFlowSettings).set({
peersJson: JSON.stringify(peers),
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}
export function recordFlowPacket(exporterIp: string) {
const row = getTrafficFlowSettingsRow()
db.update(trafficFlowSettings).set({
lastDatagramAt: nowIso(),
lastExporterIp: exporterIp,
packetsReceived: row.packetsReceived + 1,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}
export function recordFlowListenerError(message: string) {
db.update(trafficFlowSettings).set({
lastError: message,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}
export function enableTrafficFlowIngest() {
db.update(trafficFlowSettings).set({
enabled: true,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}
export function listHostPeers(): FlowHostPeer[] {
return parsePeers(getTrafficFlowSettingsRow().peersJson)
}
+125
View File
@@ -0,0 +1,125 @@
import assert from "node:assert/strict"
import {
bpsToMbps,
bucketAvg,
buildTrafficFromSamples,
isLoopbackName,
mergeBuiltTraffic,
parseMonitorTraffic,
rateBpsFromDelta,
shouldIncludeIface,
type TrafficSampleLike,
} from "./traffic-rate.js"
assert.equal(isLoopbackName("lo"), true)
assert.equal(isLoopbackName("loopback"), true)
assert.equal(isLoopbackName("ether1"), false)
assert.equal(shouldIncludeIface("lo", true, false), false)
assert.equal(shouldIncludeIface("ether1", true, false), true)
assert.equal(shouldIncludeIface("ether1", false, false), false)
assert.equal(shouldIncludeIface("ether1", true, true), false)
assert.equal(shouldIncludeIface("lo", true, false, "lo"), true)
assert.equal(rateBpsFromDelta(1000, 2000, 0, 1000), 8000)
assert.equal(rateBpsFromDelta(1000, 500, 0, 1000), null)
assert.equal(rateBpsFromDelta(1000, 2000, 1000, 1000), null)
assert.equal(bpsToMbps(1_500_000), 1.5)
assert.equal(bpsToMbps(400_000), 0.4)
const buckets = bucketAvg(
[
{ t: 0, v: 10 },
{ t: 1000, v: 20 },
],
0,
1000,
4,
)
assert.equal(buckets.length, 4)
assert.equal(buckets[0], 10)
assert.equal(buckets[3], 20)
assert.equal(buckets[1], 0)
assert.equal(buckets[2], 0)
const t0 = "2026-09-06T10:00:00.000Z"
const t1 = "2026-09-06T10:00:30.000Z"
const t2 = "2026-09-06T10:01:00.000Z"
const start = Date.parse(t0)
const end = Date.parse(t2)
const samples: TrafficSampleLike[] = [
{ interfaceName: "ether1", sampledAt: t0, rxBytes: 1_000_000, txBytes: 500_000, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "ether1", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 500_000 + 1_875_000, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "ether1", sampledAt: t2, rxBytes: 100, txBytes: 50, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "lo", sampledAt: t0, rxBytes: 0, txBytes: 0, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "lo", sampledAt: t1, rxBytes: 9_000_000, txBytes: 9_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
]
const built = buildTrafficFromSamples(samples, start, end)
assert.ok(built.rxPeak > 0, "peak RX from delta")
assert.equal(built.rxNow, 1, "last valid delta after reset skip")
assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat")
assert.ok(built.rxPeak <= 1.1, "lo excluded from peak")
const live = parseMonitorTraffic([
{ name: "ether1", "rx-bits-per-second": "2000000", "tx-bits-per-second": "500000" },
{ name: "lo", "rx-bits-per-second": "8000000", "tx-bits-per-second": "8000000" },
])
assert.equal(live.rxMbps, 2)
assert.equal(live.txMbps, 0.5)
const onceOnly = parseMonitorTraffic(
{ name: "ether1", "rx-bits-per-second": "1000000", "tx-bits-per-second": "0" },
{ onlyInterface: "ether1" },
)
assert.equal(onceOnly.rxMbps, 1)
const boundOnly = buildTrafficFromSamples(samples, start, end, ["ether1", "lo"])
assert.ok(boundOnly.rxPeak > 0, "bound list includes ether1")
assert.ok(boundOnly.rxPeak > built.rxPeak, "lo included when listed")
const merged = mergeBuiltTraffic([
buildTrafficFromSamples(samples, start, end, "ether1"),
buildTrafficFromSamples(samples, start, end, "ether1"),
])
assert.equal(merged.rxNow, built.rxNow * 2)
const wgSamples: TrafficSampleLike[] = [
{ interfaceName: "wg-msk", sampledAt: t0, rxBytes: 2_000_000, txBytes: 1_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "wg-msk", sampledAt: t1, rxBytes: 2_000_000 + 1_875_000, txBytes: 1_000_000 + 937_500, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "wg-msk", sampledAt: t2, rxBytes: 50, txBytes: 25, rxBps: 0, txBps: 0, running: true, disabled: false },
]
const userAgg = mergeBuiltTraffic([
buildTrafficFromSamples(samples, start, end, "ether1"),
buildTrafficFromSamples(wgSamples, start, end, "wg-msk"),
])
const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, ["ether1", "wg-msk"])
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface")
const peerA: TrafficSampleLike[] = [
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t0, rxBytes: 1_000_000, txBytes: 100_000, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 100_000 + 375_000, rxBps: 0, txBps: 0, running: true, disabled: false },
]
const peerB: TrafficSampleLike[] = [
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t0, rxBytes: 500_000, txBytes: 50_000, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t1, rxBytes: 500_000 + 1_875_000, txBytes: 50_000 + 187_500, rxBps: 0, txBps: 0, running: true, disabled: false },
]
const ifaceWg: TrafficSampleLike[] = [
{ interfaceName: "wg-server", sampledAt: t0, rxBytes: 10_000_000, txBytes: 2_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "wg-server", sampledAt: t1, rxBytes: 10_000_000 + 7_500_000, txBytes: 2_000_000 + 750_000, rxBps: 0, txBps: 0, running: true, disabled: false },
]
const mixed = [...peerA, ...peerB, ...ifaceWg]
const rateA = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-a")
const rateB = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-b")
const rateIface = buildTrafficFromSamples(mixed, start, end, "wg-server")
assert.ok(rateA.rxNow > 0 && rateB.rxNow > 0, "скорость по каждому пиру")
assert.notEqual(rateA.rxNow, rateB.rxNow, "два пира одного iface — разный rate")
assert.ok(rateIface.rxNow > rateA.rxNow, "iface-level не суммирует пиров")
assert.equal(
buildTrafficFromSamples(mixed, start, end).rxNow,
rateIface.rxNow,
"режим сервера игнорирует семплы пиров",
)
console.log("traffic-rate tests ok")
+282
View File
@@ -0,0 +1,282 @@
/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
export const SERIES_POINTS = 60
export interface TrafficSampleLike {
interfaceName: string
peerPublicKey?: string
sampledAt: string
rxBytes: number
txBytes: number
rxBps: number
txBps: number
running: boolean
disabled: boolean
}
export interface RatePoint {
t: number
rxMbps: number
txMbps: number
}
export interface BuiltTrafficSeries {
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotalGiB: number
txTotalGiB: number
sessions: number
rxSeries: number[]
txSeries: number[]
}
export interface MonitorLiveSample {
rxMbps: number
txMbps: number
at: string
}
export function isLoopbackName(name: string): boolean {
return /^(lo|loopback)(\d+)?$/i.test(name.trim())
}
export function shouldIncludeIface(
name: string,
running: boolean,
disabled: boolean,
onlyInterface?: string,
): boolean {
if (onlyInterface) return name === onlyInterface
if (isLoopbackName(name)) return false
return running && !disabled
}
export function bpsToMbps(bps: number): number {
if (!Number.isFinite(bps) || bps <= 0) return 0
return Math.round((bps / 1_000_000) * 1000) / 1000
}
/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */
export function rateBpsFromDelta(
prevBytes: number,
nextBytes: number,
prevAtMs: number,
nextAtMs: number,
): number | null {
const dtSec = (nextAtMs - prevAtMs) / 1000
if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null
if (nextBytes < prevBytes) return null
return Math.round(((nextBytes - prevBytes) * 8) / dtSec)
}
export function bucketAvg(
points: Array<{ t: number; v: number }>,
rangeStartMs: number,
rangeEndMs: number,
target = SERIES_POINTS,
): number[] {
const buckets = Array.from({ length: target }, () => 0)
const counts = Array.from({ length: target }, () => 0)
const span = rangeEndMs - rangeStartMs
if (span <= 0 || points.length === 0) return buckets
for (const p of points) {
const ratio = (p.t - rangeStartMs) / span
const i = Math.min(target - 1, Math.max(0, Math.floor(ratio * target)))
buckets[i] += p.v
counts[i] += 1
}
return buckets.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0))
}
function parseIsoMs(iso: string): number {
const t = Date.parse(iso)
return Number.isFinite(t) ? t : 0
}
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
return `${interfaceName}\0${peerPublicKey}`
}
export function buildTrafficFromSamples(
rows: TrafficSampleLike[],
rangeStartMs: number,
rangeEndMs: number,
onlyInterface?: string | readonly string[],
peerPublicKey?: string,
): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = {
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotalGiB: 0,
txTotalGiB: 0,
sessions: 0,
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
}
if (rows.length === 0) return empty
const bySeries = new Map<string, TrafficSampleLike[]>()
for (const r of rows) {
const peer = r.peerPublicKey ?? ""
const arr = bySeries.get(sampleSeriesKey(r.interfaceName, peer)) ?? []
arr.push(r)
bySeries.set(sampleSeriesKey(r.interfaceName, peer), arr)
}
const allowList = Array.isArray(onlyInterface)
? onlyInterface
: (typeof onlyInterface === "string" ? [onlyInterface] : null)
const rxPoints: Array<{ t: number; v: number }> = []
const txPoints: Array<{ t: number; v: number }> = []
const byTs = new Map<number, { rx: number; tx: number }>()
let rxBytesDelta = 0
let txBytesDelta = 0
let sessions = 0
for (const [key, arr] of bySeries) {
const sep = key.indexOf("\0")
const name = sep >= 0 ? key.slice(0, sep) : key
const peer = sep >= 0 ? key.slice(sep + 1) : ""
if (allowList) {
if (!allowList.includes(name)) continue
} else if (isLoopbackName(name)) {
continue
}
if (peerPublicKey === undefined) {
if (peer !== "") continue
} else if (peer !== peerPublicKey) {
continue
}
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1]
if (!last) continue
if (!allowList && (!last.running || last.disabled)) continue
if (last.running && !last.disabled) sessions += 1
const first = sorted[0]
if (first) {
const dRx = last.rxBytes - first.rxBytes
const dTx = last.txBytes - first.txBytes
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
}
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const cur = sorted[i]
if (!prev || !cur) continue
if (!allowList && (!cur.running || cur.disabled)) continue
const t0 = parseIsoMs(prev.sampledAt)
const t1 = parseIsoMs(cur.sampledAt)
const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1)
if (rxBps == null && txBps == null) continue
const rxMbps = bpsToMbps(rxBps ?? 0)
const txMbps = bpsToMbps(txBps ?? 0)
const acc = byTs.get(t1) ?? { rx: 0, tx: 0 }
acc.rx += rxMbps
acc.tx += txMbps
byTs.set(t1, acc)
}
}
for (const [t, v] of byTs) {
rxPoints.push({ t, v: v.rx })
txPoints.push({ t, v: v.tx })
}
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
const last = lastTs != null ? byTs.get(lastTs) : undefined
const rxPeak = rxPoints.reduce((m, p) => Math.max(m, p.v), 0)
const txPeak = txPoints.reduce((m, p) => Math.max(m, p.v), 0)
return {
rxNow: last?.rx ?? 0,
txNow: last?.tx ?? 0,
rxPeak,
txPeak,
rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
sessions,
rxSeries,
txSeries,
}
}
export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = {
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotalGiB: 0,
txTotalGiB: 0,
sessions: 0,
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
}
if (parts.length === 0) return empty
const acc = {
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotalGiB: 0,
txTotalGiB: 0,
sessions: 0,
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
}
for (const p of parts) {
acc.rxNow += p.rxNow
acc.txNow += p.txNow
acc.rxPeak += p.rxPeak
acc.txPeak += p.txPeak
acc.rxTotalGiB += p.rxTotalGiB
acc.txTotalGiB += p.txTotalGiB
acc.sessions += p.sessions
for (let i = 0; i < SERIES_POINTS; i++) {
acc.rxSeries[i] += p.rxSeries[i] ?? 0
acc.txSeries[i] += p.txSeries[i] ?? 0
}
}
acc.rxTotalGiB = Number(acc.rxTotalGiB.toFixed(1))
acc.txTotalGiB = Number(acc.txTotalGiB.toFixed(1))
return acc
}
export function parseMonitorTraffic(
raw: unknown,
opts?: { onlyInterface?: string },
): MonitorLiveSample {
const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
let rxBps = 0
let txBps = 0
for (const item of items) {
if (!item || typeof item !== "object") continue
const rec = item as Record<string, unknown>
const name = String(rec.name ?? rec.interface ?? "")
if (opts?.onlyInterface) {
if (name && name !== opts.onlyInterface) continue
} else if (isLoopbackName(name)) {
continue
}
rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0
txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0
}
return {
rxMbps: bpsToMbps(rxBps),
txMbps: bpsToMbps(txBps),
at: new Date().toISOString(),
}
}
+133
View File
@@ -0,0 +1,133 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { listUsers } from "../modules/users/service/users-service.js"
import { readServerSamplesInRange } from "./traffic-collector.js"
import {
buildTrafficFromSamples,
mergeBuiltTraffic,
type BuiltTrafficSeries,
} from "./traffic-rate.js"
export interface BoundIfaceTrafficDto {
id: string
bindingId: string
userId: string
userLogin: string
userName: string
interfaceName: string
interfaceType: string
peerPublicKey: string
peerName: string
comment: string
serverId: string
serverName: string
serverSite: string
serverCountry: string
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxSeries: number[]
txSeries: number[]
status: "online" | "offline"
}
export interface UserTrafficDto {
id: string
login: string
displayName: string
role: string
active: boolean
interfaces: BoundIfaceTrafficDto[]
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxSeries: number[]
txSeries: number[]
}
function seriesFromBuilt(built: BuiltTrafficSeries) {
return {
rxNow: built.rxNow,
txNow: built.txNow,
rxPeak: built.rxPeak,
txPeak: built.txPeak,
rxTotal: built.rxTotalGiB,
txTotal: built.txTotalGiB,
rxSeries: built.rxSeries,
txSeries: built.txSeries,
}
}
function serverStatus(serverId: number): "online" | "offline" {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!row?.enabled) return "offline"
return "online"
}
export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): UserTrafficDto[] {
const sinceIso = new Date(rangeStartMs).toISOString()
const users = listUsers()
const sampleCache = new Map<number, ReturnType<typeof readServerSamplesInRange>>()
return users.map((user) => {
const parts: BuiltTrafficSeries[] = []
const interfaces: BoundIfaceTrafficDto[] = []
for (const b of user.bindings) {
let rows = sampleCache.get(b.serverId)
if (!rows) {
rows = readServerSamplesInRange(b.serverId, sinceIso)
sampleCache.set(b.serverId, rows)
}
const peerKey = b.peerPublicKey ?? ""
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName, peerKey)
parts.push(built)
const last = [...rows.filter((r) =>
r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey,
)]
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt))
.at(-1)
const running = Boolean(last?.running) && !last?.disabled
interfaces.push({
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${peerKey || "_iface"}`,
bindingId: b.id,
userId: user.id,
userLogin: user.login,
userName: user.name,
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: peerKey,
peerName: b.peerName ?? "",
comment: b.comment,
serverId: String(b.serverId),
serverName: b.serverName,
serverSite: b.serverSite,
serverCountry: b.serverCountry,
...seriesFromBuilt(built),
status: running && serverStatus(b.serverId) === "online" ? "online" : "offline",
})
}
const merged = mergeBuiltTraffic(parts)
return {
id: user.id,
login: user.login,
displayName: user.name,
role: user.role,
active: user.active,
interfaces,
...seriesFromBuilt(merged),
}
})
}
export function buildBoundInterfaceTraffic(rangeStartMs: number, rangeEndMs: number): BoundIfaceTrafficDto[] {
return buildUserTrafficList(rangeStartMs, rangeEndMs).flatMap((u) => u.interfaces)
}
+12
View File
@@ -0,0 +1,12 @@
import { generateKeyPairSync } from "node:crypto"
/** WireGuard Curve25519 keypair as RouterOS/wg-quick base64 (32 bytes). */
export function generateWireGuardKeyPair(): { publicKey: string; privateKey: string } {
const { publicKey, privateKey } = generateKeyPairSync("x25519")
const pubDer = publicKey.export({ type: "spki", format: "der" })
const privDer = privateKey.export({ type: "pkcs8", format: "der" })
return {
publicKey: Buffer.from(pubDer.subarray(-32)).toString("base64"),
privateKey: Buffer.from(privDer.subarray(-32)).toString("base64"),
}
}
@@ -0,0 +1,100 @@
import assert from "node:assert/strict"
import {
detectWgConfigFormat,
generateMikrotikRsc,
generateNativeConf,
parseMikrotikRsc,
parseNativeConf,
parseWgConfig,
} from "./wireguard-config.js"
const sampleConf = `[Interface]
PrivateKey = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=
Address = 10.210.0.1/30
ListenPort = 13231
MTU = 1420
[Peer]
PublicKey = bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=
AllowedIPs = 10.210.0.2/32, 192.168.20.0/24
Endpoint = 10.0.1.1:13231
PersistentKeepalive = 25
`
const parsedConf = parseNativeConf(sampleConf)
assert.equal(parsedConf.format, "conf")
assert.equal(parsedConf.interface.listenPort, 13231)
assert.equal(parsedConf.interface.address, "10.210.0.1/30")
assert.equal(parsedConf.peers.length, 1)
assert.equal(parsedConf.peers[0]?.endpointAddress, "10.0.1.1")
assert.equal(parsedConf.peers[0]?.endpointPort, 13231)
assert.deepEqual(parsedConf.peers[0]?.allowedAddresses, ["10.210.0.2/32", "192.168.20.0/24"])
const roundConf = generateNativeConf({
name: "wg0",
listenPort: parsedConf.interface.listenPort ?? 13231,
mtu: parsedConf.interface.mtu ?? 1420,
privateKey: parsedConf.interface.privateKey,
address: parsedConf.interface.address,
peers: parsedConf.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedAddresses,
endpoint: p.endpointAddress
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
: undefined,
persistentKeepalive: p.persistentKeepalive,
})),
})
const reparsed = parseNativeConf(roundConf)
assert.equal(reparsed.interface.privateKey, parsedConf.interface.privateKey)
assert.equal(reparsed.peers[0]?.publicKey, parsedConf.peers[0]?.publicKey)
const sampleRsc = `# WireGuard
/interface wireguard add \\
name=wg-msk-spb \\
listen-port=13231 \\
mtu=1420 \\
comment="MSK → SPB"
/ip address add \\
address=10.210.0.1/30 \\
interface=wg-msk-spb
/interface wireguard peers add \\
interface=wg-msk-spb \\
public-key="SPBPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=" \\
allowed-address=10.210.0.2/32,192.168.20.0/24 \\
endpoint-address=10.0.1.1 \\
endpoint-port=13231 \\
persistent-keepalive=25
`
assert.equal(detectWgConfigFormat(sampleRsc), "rsc")
assert.equal(detectWgConfigFormat(sampleConf), "conf")
const parsedRsc = parseMikrotikRsc(sampleRsc)
assert.equal(parsedRsc.interface.name, "wg-msk-spb")
assert.equal(parsedRsc.interface.address, "10.210.0.1/30")
assert.equal(parsedRsc.peers.length, 1)
assert.equal(parsedRsc.peers[0]?.endpointPort, 13231)
const generatedRsc = generateMikrotikRsc({
name: parsedRsc.interface.name,
listenPort: parsedRsc.interface.listenPort ?? 13231,
mtu: parsedRsc.interface.mtu ?? 1420,
comment: parsedRsc.interface.comment,
address: parsedRsc.interface.address,
peers: parsedRsc.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedAddresses,
endpoint: p.endpointAddress
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
: undefined,
persistentKeepalive: p.persistentKeepalive,
})),
})
const rscAgain = parseWgConfig(generatedRsc, "rsc")
assert.equal(rscAgain.interface.name, "wg-msk-spb")
assert.equal(rscAgain.peers[0]?.publicKey, parsedRsc.peers[0]?.publicKey)
console.log("wireguard-config tests ok")
+359
View File
@@ -0,0 +1,359 @@
/**
* WireGuard config codecs: native .conf MikroTik .rsc
*/
export type WgParsedPeer = {
publicKey: string
allowedAddresses: string[]
endpointAddress?: string
endpointPort?: number
persistentKeepalive?: number
comment?: string
name?: string
privateKey?: "auto" | "none" | string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
disabled?: boolean
}
export type WgParsedInterface = {
name: string
listenPort?: number
mtu?: number
privateKey?: string
comment?: string
address?: string
disabled?: boolean
}
export type WgParsedConfig = {
format: "rsc" | "conf"
interface: WgParsedInterface
peers: WgParsedPeer[]
}
export type WgExportIface = {
name: string
listenPort?: number
mtu: number
comment?: string
enabled?: boolean
privateKey?: string
publicKey?: string
address?: string
serverName?: string
peers: Array<{
publicKey: string
allowedIps: string[]
endpoint?: string
persistentKeepalive?: number
persistent?: boolean
comment?: string
name?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
}>
}
function stripQuotes(v: string): string {
const t = v.trim()
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1)
}
return t
}
function parseKvLine(line: string): Record<string, string> {
const out: Record<string, string> = {}
// Match key=value pairs; values may be quoted
const re = /([a-zA-Z0-9_-]+)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s\\]+)/g
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
out[m[1]] = stripQuotes(m[2])
}
return out
}
function joinContinuedLines(text: string): string[] {
const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
const lines: string[] = []
let buf = ""
for (const line of raw) {
const trimmedEnd = line.replace(/\s+$/, "")
if (trimmedEnd.endsWith("\\")) {
buf += trimmedEnd.slice(0, -1).trimEnd() + " "
continue
}
buf += trimmedEnd
if (buf.trim()) lines.push(buf.trim())
buf = ""
}
if (buf.trim()) lines.push(buf.trim())
return lines
}
export function detectWgConfigFormat(content: string): "rsc" | "conf" {
const t = content.trim()
if (/\[Interface\]/i.test(t) || /\[Peer\]/i.test(t)) return "conf"
if (/\/interface\s+wireguard/i.test(t) || /\/interface\/wireguard/i.test(t)) return "rsc"
if (/PrivateKey\s*=/i.test(t) || /PublicKey\s*=/i.test(t)) return "conf"
return "rsc"
}
export function parseNativeConf(content: string): WgParsedConfig {
const lines = content.replace(/\r\n/g, "\n").split("\n")
let section: "interface" | "peer" | null = null
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let currentPeer: WgParsedPeer | null = null
const flushPeer = () => {
if (currentPeer?.publicKey) peers.push(currentPeer)
currentPeer = null
}
for (const raw of lines) {
const line = raw.trim()
if (!line || line.startsWith("#") || line.startsWith(";")) continue
if (/^\[Interface\]$/i.test(line)) {
flushPeer()
section = "interface"
continue
}
if (/^\[Peer\]$/i.test(line)) {
flushPeer()
section = "peer"
currentPeer = { publicKey: "", allowedAddresses: [] }
continue
}
const eq = line.indexOf("=")
if (eq < 0) continue
const key = line.slice(0, eq).trim().toLowerCase()
const value = line.slice(eq + 1).trim()
if (section === "interface") {
if (key === "privatekey") iface.privateKey = value
else if (key === "address") iface.address = value.split(",")[0]?.trim()
else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined
else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined
else if (key === "name") iface.name = value || iface.name
} else if (section === "peer" && currentPeer) {
if (key === "publickey") currentPeer.publicKey = value
else if (key === "allowedips") {
currentPeer.allowedAddresses = value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
} else if (key === "endpoint") {
const lastColon = value.lastIndexOf(":")
if (lastColon > 0 && !value.includes("]:")) {
currentPeer.endpointAddress = value.slice(0, lastColon)
currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined
} else if (value.startsWith("[") && value.includes("]:")) {
const idx = value.indexOf("]:")
currentPeer.endpointAddress = value.slice(1, idx)
currentPeer.endpointPort = Number.parseInt(value.slice(idx + 2), 10) || undefined
} else {
currentPeer.endpointAddress = value
}
} else if (key === "persistentkeepalive") {
currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined
} else if (key === "presharedkey") {
// ignore PSK for ROS import for now
}
}
}
flushPeer()
if (!iface.name) iface.name = "wg0"
return { format: "conf", interface: iface, peers }
}
export function parseMikrotikRsc(content: string): WgParsedConfig {
const lines = joinContinuedLines(content)
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let foundIface = false
for (const line of lines) {
if (line.startsWith("#")) continue
const lower = line.toLowerCase()
if (
lower.startsWith("/interface wireguard add") ||
lower.startsWith("/interface/wireguard add")
) {
const kv = parseKvLine(line)
if (kv.name) iface.name = kv.name
if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined
if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined
if (kv["private-key"]) iface.privateKey = kv["private-key"]
if (kv.comment) iface.comment = kv.comment
if (kv.disabled === "yes") iface.disabled = true
foundIface = true
continue
}
if (
lower.startsWith("/interface wireguard peers add") ||
lower.startsWith("/interface/wireguard/peers add")
) {
const kv = parseKvLine(line)
const allowed = (kv["allowed-address"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
peers.push({
publicKey: kv["public-key"] ?? "",
allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"],
endpointAddress: kv["endpoint-address"],
endpointPort: kv["endpoint-port"]
? Number.parseInt(kv["endpoint-port"], 10) || undefined
: undefined,
persistentKeepalive: kv["persistent-keepalive"]
? Number.parseInt(kv["persistent-keepalive"], 10) || undefined
: undefined,
comment: kv.comment,
name: kv.name,
clientAddress: kv["client-address"],
clientDns: kv["client-dns"],
clientEndpoint: kv["client-endpoint"],
disabled: kv.disabled === "yes",
})
continue
}
if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add")) {
const kv = parseKvLine(line)
if (kv.address) iface.address = kv.address
continue
}
}
if (!foundIface && peers.length === 0) {
throw new Error("Не удалось распознать RouterOS WireGuard .rsc")
}
return { format: "rsc", interface: iface, peers }
}
export function parseWgConfig(
content: string,
format: "auto" | "rsc" | "conf" = "auto",
): WgParsedConfig {
const detected = format === "auto" ? detectWgConfigFormat(content) : format
if (detected === "conf") return parseNativeConf(content)
return parseMikrotikRsc(content)
}
export function generateNativeConf(iface: WgExportIface, opts?: { includePrivateKey?: boolean }): string {
const lines: string[] = []
lines.push(`[Interface]`)
if (opts?.includePrivateKey && iface.privateKey) {
lines.push(`PrivateKey = ${iface.privateKey}`)
} else if (iface.privateKey) {
lines.push(`PrivateKey = ${iface.privateKey}`)
} else {
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
}
if (iface.address) lines.push(`Address = ${iface.address}`)
if (iface.listenPort) lines.push(`ListenPort = ${iface.listenPort}`)
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
lines.push(``)
for (const p of iface.peers) {
lines.push(`[Peer]`)
lines.push(`PublicKey = ${p.publicKey}`)
lines.push(`AllowedIPs = ${p.allowedIps.join(", ")}`)
if (p.endpoint) lines.push(`Endpoint = ${p.endpoint}`)
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(`PersistentKeepalive = ${ka}`)
if (p.comment) lines.push(`# ${p.comment}`)
lines.push(``)
}
return lines.join("\n").trimEnd() + "\n"
}
export function generatePeerClientConf(args: {
peerPrivateKey?: string
peerAddress?: string
peerDns?: string
serverPublicKey: string
allowedIps?: string[]
endpoint?: string
persistentKeepalive?: number
}): string {
const lines: string[] = []
lines.push(`[Interface]`)
lines.push(
args.peerPrivateKey
? `PrivateKey = ${args.peerPrivateKey}`
: `# PrivateKey = <ключ клиента>`,
)
if (args.peerAddress) lines.push(`Address = ${args.peerAddress}`)
if (args.peerDns) lines.push(`DNS = ${args.peerDns}`)
lines.push(``)
lines.push(`[Peer]`)
lines.push(`PublicKey = ${args.serverPublicKey}`)
lines.push(`AllowedIPs = ${(args.allowedIps?.length ? args.allowedIps : ["0.0.0.0/0"]).join(", ")}`)
if (args.endpoint) lines.push(`Endpoint = ${args.endpoint}`)
if (args.persistentKeepalive != null && args.persistentKeepalive > 0) {
lines.push(`PersistentKeepalive = ${args.persistentKeepalive}`)
}
lines.push(``)
return lines.join("\n")
}
export function generateMikrotikRsc(iface: WgExportIface): string {
const lines: string[] = []
lines.push(`# WireGuard — ${iface.name}${iface.serverName ? ` · ${iface.serverName}` : ""}`)
lines.push(`# RouterOS 7.x · MikrotikManager`)
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort ?? 13231} \\`)
lines.push(` mtu=${iface.mtu} \\`)
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
if (iface.enabled === false) lines.push(` disabled=yes \\`)
// remove trailing backslash on last iface param by rewriting last line
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
if (iface.address) {
lines.push(`/ip address add \\`)
lines.push(` address=${iface.address} \\`)
lines.push(` interface=${iface.name}`)
lines.push(``)
}
for (const p of iface.peers) {
lines.push(`/interface wireguard peers add \\`)
lines.push(` interface=${iface.name} \\`)
lines.push(` public-key="${p.publicKey}" \\`)
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
if (p.endpoint) {
const host = p.endpoint.includes(":") ? p.endpoint.slice(0, p.endpoint.lastIndexOf(":")) : p.endpoint
const port = p.endpoint.includes(":")
? p.endpoint.slice(p.endpoint.lastIndexOf(":") + 1)
: "13231"
lines.push(` endpoint-address=${host} \\`)
lines.push(` endpoint-port=${port} \\`)
}
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(` persistent-keepalive=${ka} \\`)
if (p.name) lines.push(` name=${p.name} \\`)
if (p.clientAddress) lines.push(` client-address=${p.clientAddress} \\`)
if (p.clientDns) lines.push(` client-dns=${p.clientDns} \\`)
if (p.clientEndpoint) lines.push(` client-endpoint=${p.clientEndpoint} \\`)
if (p.comment) lines.push(` comment="${p.comment.replace(/"/g, '\\"')}" \\`)
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
}
return lines.join("\n")
}
+261
View File
@@ -0,0 +1,261 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import type { WgIfaceDto, WgPeerDto } from "@mmapp/contracts/wireguard"
type ServerRow = typeof servers.$inferSelect
interface RosWireGuard {
".id"?: string
name?: string
"listen-port"?: string
mtu?: string
"public-key"?: string
"private-key"?: string
running?: string
disabled?: string
comment?: string
}
interface RosWireGuardPeer {
".id"?: string
interface?: string
name?: string
"public-key"?: string
"endpoint-address"?: string
"endpoint-port"?: string
"allowed-address"?: string
"last-handshake"?: string
rx?: string
tx?: string
disabled?: string
comment?: string
"persistent-keepalive"?: string
"client-address"?: string
"client-dns"?: string
"client-endpoint"?: string
}
interface RosIpAddress {
".id"?: string
address?: string
interface?: string
disabled?: string
}
function parseBytes(v: string | undefined): number | undefined {
if (v == null || v === "") return undefined
const n = Number.parseInt(v, 10)
return Number.isFinite(n) ? n : undefined
}
function mapPeer(p: RosWireGuardPeer, idx: number): WgPeerDto {
const rosId = String(p[".id"] ?? `peer-${idx}`)
const allowed = (p["allowed-address"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
const epAddr = (p["endpoint-address"] ?? "").trim()
const epPort = (p["endpoint-port"] ?? "").trim()
const endpoint = epAddr ? (epPort ? `${epAddr}:${epPort}` : epAddr) : undefined
const ka = p["persistent-keepalive"]
? Number.parseInt(p["persistent-keepalive"], 10)
: undefined
return {
id: rosId,
rosId,
publicKey: p["public-key"] ?? "",
allowedIps: allowed,
endpoint,
latestHandshake: p["last-handshake"]?.trim() || undefined,
transferRx: parseBytes(p.rx),
transferTx: parseBytes(p.tx),
persistentKeepalive: Number.isFinite(ka) ? ka : undefined,
persistent: Number.isFinite(ka) && (ka as number) > 0,
comment: p.comment ?? undefined,
disabled: p.disabled === "true" || p.disabled === "yes",
name: p.name,
clientAddress: p["client-address"],
clientDns: p["client-dns"],
clientEndpoint: p["client-endpoint"],
}
}
function mapIface(
server: ServerRow,
w: RosWireGuard,
peers: WgPeerDto[],
address: string | undefined,
includePrivateKey: boolean,
): WgIfaceDto {
const rosId = String(w[".id"] ?? w.name ?? "wg")
const name = (w.name ?? "").trim() || rosId
const disabled = w.disabled === "true" || w.disabled === "yes"
const running = w.running === "true" || w.running === "yes"
return {
id: `${server.id}:${rosId}`,
rosId,
name,
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
serverCountry: server.country ?? undefined,
listenPort: Number.parseInt(w["listen-port"] ?? "13231", 10) || 13231,
mtu: Number.parseInt(w.mtu ?? "1420", 10) || 1420,
publicKey: w["public-key"] || undefined,
privateKey: includePrivateKey ? w["private-key"] || undefined : undefined,
address,
peers,
comment: w.comment ?? "",
enabled: !disabled,
status: disabled ? "down" : running ? "up" : "down",
}
}
async function fetchForServer(
server: ServerRow,
includePrivateKey: boolean,
): Promise<WgIfaceDto[]> {
const client = MikrotikClient.fromServer(server)
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
client.get<RosWireGuard[]>("/interface/wireguard"),
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
])
const peersByIface = new Map<string, WgPeerDto[]>()
peersRaw.forEach((p, idx) => {
const ifaceName = (p.interface ?? "").trim()
if (!ifaceName) return
const list = peersByIface.get(ifaceName) ?? []
list.push(mapPeer(p, idx))
peersByIface.set(ifaceName, list)
})
const addrByIface = new Map<string, string>()
for (const a of addrsRaw) {
if (a.disabled === "true" || a.disabled === "yes") continue
const iface = (a.interface ?? "").trim()
const addr = (a.address ?? "").trim()
if (iface && addr && !addrByIface.has(iface)) addrByIface.set(iface, addr)
}
return ifacesRaw.map((w) => {
const name = (w.name ?? "").trim()
return mapIface(
server,
w,
peersByIface.get(name) ?? [],
addrByIface.get(name),
includePrivateKey,
)
})
}
export type WgListResult = {
interfaces: WgIfaceDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
}
export async function listWireGuardInterfaces(opts?: {
serverId?: string
includePrivateKey?: boolean
}): Promise<WgListResult> {
const includePrivateKey = opts?.includePrivateKey === true
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
return { interfaces: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
serverRows = row ? [row] : []
} else {
serverRows = db.select().from(servers).where(eq(servers.enabled, true)).all()
}
const failures: WgListResult["failures"] = []
const results = await Promise.all(
serverRows.map(async (server) => {
try {
return await fetchForServer(server, includePrivateKey)
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name ?? undefined,
error: e instanceof Error ? e.message : String(e),
})
return [] as WgIfaceDto[]
}
}),
)
return { interfaces: results.flat(), failures }
}
export async function countWireGuardInterfaces(): Promise<number> {
try {
const result = await Promise.race([
listWireGuardInterfaces({ includePrivateKey: false }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.interfaces.length
} catch {
return 0
}
}
export function getEnabledServerById(serverId: string | number): ServerRow | null {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
}
export type CatalogWgPeer = {
interfaceName: string
publicKey: string
name: string
comment: string
allowedIps: string[]
latestHandshake?: string
disabled: boolean
}
const WG_CATALOG_TIMEOUT_MS = 5_000
export async function listWireGuardPeersForCatalog(serverId: number): Promise<{
peers: CatalogWgPeer[]
error?: string
}> {
const row = getEnabledServerById(serverId)
if (!row) return { peers: [], error: "Сервер не найден" }
try {
const client = MikrotikClient.fromServer(row)
const peersRaw = await Promise.race([
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Таймаут RouterOS")), WG_CATALOG_TIMEOUT_MS)
}),
])
const peers: CatalogWgPeer[] = peersRaw.flatMap((p, idx) => {
const mapped = mapPeer(p, idx)
const interfaceName = (p.interface ?? "").trim()
const publicKey = mapped.publicKey.trim()
if (!interfaceName || !publicKey) return []
return [{
interfaceName,
publicKey,
name: mapped.name ?? "",
comment: mapped.comment ?? "",
allowedIps: mapped.allowedIps,
latestHandshake: mapped.latestHandshake,
disabled: mapped.disabled === true,
}]
})
return { peers }
} catch (e) {
return { peers: [], error: e instanceof Error ? e.message : String(e) }
}
}
export { type RosWireGuard, type RosWireGuardPeer }
+50
View File
@@ -0,0 +1,50 @@
import type { MikrotikClient } from "./mikrotik.js"
/** Общие PUT iface / peer / address для `/wireguard` и traffic-flow overlay. */
export 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
}
export function asRosArray<T>(raw: unknown): T[] {
if (Array.isArray(raw)) return raw as T[]
if (raw && typeof raw === "object") return [raw as T]
return []
}
export function rosRowId(row: Record<string, unknown>): string {
return String(row[".id"] ?? row.id ?? "")
}
export async function putWireguardInterface(
client: MikrotikClient,
fields: Record<string, string | undefined>,
): Promise<void> {
await client.put("/interface/wireguard", toRosBody(fields))
}
export async function putIpAddress(
client: MikrotikClient,
address: string,
iface: string,
): Promise<void> {
await client.put("/ip/address", { address, interface: iface })
}
export async function putWireguardPeer(
client: MikrotikClient,
fields: Record<string, string | undefined>,
): Promise<void> {
await client.put("/interface/wireguard/peers", toRosBody(fields))
}
export async function patchRosPath(
client: MikrotikClient,
path: string,
fields: Record<string, string | undefined>,
): Promise<void> {
await client.patch(path, toRosBody(fields))
}
+16
View File
@@ -345,8 +345,24 @@ export interface RosFirewallFilter {
"dynamic"?: string
"bytes"?: string
"packets"?: string
"log"?: string
"log-prefix"?: string
}
export interface RosFirewallAddressList {
".id": string
list: string
address: string
comment?: string
disabled?: string
timeout?: string
dynamic?: string
"creation-time"?: string
}
export type FirewallFamily = "ip" | "ip6"
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
export interface RosLogEntry {
".id": string
"time": string
+17 -21
View File
@@ -35,9 +35,11 @@ import {
ShieldCheckIcon,
BoxIcon,
BadgeCheckIcon,
UsersIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { requestJson } from "@/shared/api/http-client"
import {
formatSidebarBadgeCount,
mockSidebarBadgesByUrl,
@@ -69,6 +71,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
label: "Управление",
items: [
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
{ title: "Пользователи", url: "/users", icon: <UsersIcon /> },
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
@@ -101,10 +104,10 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
},
]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number; users?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl } = useDataSource()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false)
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
@@ -118,30 +121,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, [])
React.useEffect(() => {
if (mode !== "live") {
setLiveCounts(null)
if (!prefsHydrated || mode !== "live") {
if (mode !== "live") setLiveCounts(null)
return
}
let cancelled = false
const load = async () => {
try {
const base = backendUrl.replace(/\/$/, "")
const [cRes, gRes] = await Promise.all([
fetch(`${base}/api/sidebar-counts`),
fetch(`${base}/api/filters/gre-tunnels`),
const [cJson, gJson] = await Promise.all([
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch(
() => ({ tunnels: [] as unknown[] }),
),
])
if (cancelled) return
if (!cRes.ok) {
setLiveCounts(null)
return
}
const cJson = (await cRes.json()) as SidebarCountsDto
let greN = 0
if (gRes.ok) {
const gJson = (await gRes.json()) as { tunnels?: unknown[] }
greN = (gJson.tunnels ?? []).length
}
setLiveCounts({ ...cJson, greTunnels: greN })
setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length })
} catch {
if (!cancelled) setLiveCounts(null)
}
@@ -152,7 +146,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendUrl])
}, [mode, backendUrl, prefsHydrated])
const navGroups = React.useMemo((): NavGroup[] => {
function badgeFor(url: string): string | undefined {
@@ -169,12 +163,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (!liveCounts) return undefined
if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers)
if (url === "/users") return formatSidebarBadgeCount(liveCounts.users ?? 0)
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0)
if (url === "/wireguard" || url === "/containers" || url === "/bgp") {
if (url === "/containers" || url === "/bgp") {
return undefined
}
+51
View File
@@ -0,0 +1,51 @@
"use client"
import { useEffect, useState, type ReactNode } from "react"
import {
ensureAuthConfig,
getClaims,
getToken,
isAuthEnabled,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
} from "@/lib/auth"
export function AuthGuard({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
void (async () => {
await ensureAuthConfig()
if (cancelled) return
if (!isAuthEnabled()) {
setReady(true)
return
}
const claims = getClaims()
if (!getToken() || !claims) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
return
}
if (!claims.apps.includes("mm")) {
window.location.assign("/access-denied")
return
}
setReady(true)
})()
return () => {
cancelled = true
}
}, [])
if (!ready) {
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
Проверка сессии
</div>
)
}
return children
}
+2 -1
View File
@@ -8,7 +8,7 @@ import {
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon,
} from "lucide-react"
// ─── Command item definition ──────────────────────────────────────────────────
@@ -36,6 +36,7 @@ const ALL_ITEMS: CommandItem[] = [
{ id: "communities", title: "BGP Communities", group: "Данные", url: "/communities", icon: <TagIcon />, keywords: ["community","bgp","теги"] },
// Управление
{ id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: <ServerIcon />, keywords: ["router","mikrotik","сервер","routeros"] },
{ id: "users", title: "Пользователи", group: "Управление", url: "/users", icon: <UsersIcon />, keywords: ["user","клиент","оператор","привязка","интерфейс"] },
{ id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: <FilterIcon />, keywords: ["filter","routing","маршрутизация"] },
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
@@ -8,7 +8,7 @@ import {
} from "@tanstack/react-table"
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
@@ -23,8 +23,11 @@ import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CONTAINER_CLASS,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
import type { DragEndEvent } from "@dnd-kit/core"
import { EmptyState } from "@/components/empty-state"
import {
CopyIcon,
@@ -89,16 +92,41 @@ interface FirewallRulesDataGridProps {
rules: FirewallRule[]
onToggle: (id: string) => void
onEdit: (rule: FirewallRule) => void
onDelete?: (rule: FirewallRule) => void
onReorder?: (activeId: string, overId: string) => void
showServer?: boolean
}
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
function FirewallRulesDataGrid({
rules,
onToggle,
onEdit,
onDelete,
onReorder,
showServer = false,
}: FirewallRulesDataGridProps) {
const indexedRules = useMemo(
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
[rules],
)
const reorderable = Boolean(onReorder)
const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>(
() => [
...(reorderable
? [{
id: "drag",
header: () => <span className="sr-only">Порядок</span>,
enableSorting: false,
cell: () => <DataGridTableDndRowHandle />,
size: 40,
meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
} satisfies ColumnDef<FirewallRule & { _index: number }>]
: []),
{
id: "index",
accessorKey: "_index",
@@ -114,10 +142,23 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
),
size: 48,
meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
headerClassName: indexPad,
cellClassName: indexPad,
},
},
...(showServer
? [{
id: "server",
accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground truncate">
{row.original.serverName || row.original.serverId || "—"}
</span>
),
meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
} satisfies ColumnDef<FirewallRule & { _index: number }>]
: []),
{
id: "chain",
accessorKey: "chain",
@@ -261,7 +302,7 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
{r.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<DropdownMenuItem variant="destructive" onClick={() => onDelete?.(r)}>
<Trash2Icon className="size-4" />
Удалить правило
</DropdownMenuItem>
@@ -274,17 +315,24 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[onEdit, onToggle],
[onEdit, onToggle, onDelete, showServer, reorderable, indexPad],
)
const table = useReactTable({
data: indexedRules,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(reorderable ? {} : { getSortedRowModel: getSortedRowModel() }),
enableSorting: !reorderable,
getRowId: (row) => row.id,
})
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id || !onReorder) return
onReorder(String(active.id), String(over.id))
}
if (rules.length === 0) {
return (
<EmptyState
@@ -304,7 +352,16 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
}}
/>
>
{reorderable ? (
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
<DataGridTableDndRows
dataIds={indexedRules.map((r) => r.id)}
handleDragEnd={handleDragEnd}
/>
</DataGridContainer>
) : undefined}
</DataGridShell>
)
}
@@ -1,167 +0,0 @@
"use client"
import { useMemo } from "react"
import type { Server } from "@/lib/data"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { cn } from "@/lib/utils"
type PermLevel = "none" | "read" | "write"
type Role = "admin" | "operator" | "viewer"
interface SectionPerm {
section: string
level: PermLevel
}
interface ServerPerm {
serverId: string
level: PermLevel
}
export interface AccessSummaryUser {
id: string
name: string
avatar: string
active: boolean
role: Role
sections: SectionPerm[]
servers: ServerPerm[]
}
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{avatar}
</div>
)
}
interface SettingsAccessSummaryDataGridProps {
users: AccessSummaryUser[]
servers: Server[]
allSectionsCount: number
}
function SettingsAccessSummaryDataGrid({
users,
servers,
allSectionsCount,
}: SettingsAccessSummaryDataGridProps) {
const columns = useMemo<CompactDataGridColumn<AccessSummaryUser>[]>(
() => [
{
id: "name",
header: "Пользователь",
accessorKey: "name",
cell: (u) => (
<div className="flex items-center gap-2">
<AvatarCircle avatar={u.avatar} active={u.active} />
<span className="text-sm font-medium">{u.name}</span>
</div>
),
},
{
id: "sections",
header: "Разделы",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
const readSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "read").map((s) => s.section)
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({allSectionsCount})
</span>
) : (
<span>{readSections.length + writeSections.length} из {allSectionsCount}</span>
)}
</span>
)
},
},
{
id: "servers",
header: "Серверы",
enableSorting: false,
cell: (u) => {
const accessServers =
u.role === "admin"
? servers
: servers.filter((s) => u.servers.find((p) => p.serverId === s.id && p.level !== "none"))
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({servers.length})
</span>
) : (
<span>
{accessServers.length} из {servers.length}
</span>
)}
</span>
)
},
},
{
id: "write",
header: "Права записи",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
return (
<div className="flex flex-wrap gap-1">
{u.role === "admin" ? (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">
Полный доступ
</span>
) : writeSections.length === 0 ? (
<span className="text-[10px] text-muted-foreground">Только просмотр</span>
) : (
<>
{writeSections.slice(0, 3).map((s) => (
<span
key={s}
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
>
{s}
</span>
))}
{writeSections.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
)}
</>
)}
</div>
)
},
},
],
[allSectionsCount, servers],
)
return (
<CompactDataGrid
data={users}
columns={columns}
emptyTitle="Нет пользователей"
/>
)
}
export { SettingsAccessSummaryDataGrid, type SettingsAccessSummaryDataGridProps }
+3 -3
View File
@@ -1,6 +1,6 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils"
import {
CompactDataGrid,
@@ -35,11 +35,11 @@ function SnapshotOkBadge({
errLabel?: string
}) {
return ok ? (
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
<Badge variant="success-outline" size="sm" className="text-[10px]">
{okLabel}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
<Badge variant="destructive-outline" size="sm" className="text-[10px]">
{errLabel}
</Badge>
)
@@ -0,0 +1,129 @@
"use client"
import { useMemo } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { fmtRate } from "@/lib/fmt-rate"
import { cn } from "@/lib/utils"
function formatBytes(n: number): string {
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
return `${n} Б`
}
function TrafficFlowsDataGrid({
rows,
emptyHint,
}: {
rows: FlowTalkerDto[]
emptyHint?: string
}) {
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
() => [
{
id: "server",
accessorKey: "serverName",
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "src",
accessorKey: "src",
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.src}
{row.original.srcPort ? `:${row.original.srcPort}` : ""}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "dst",
accessorKey: "dst",
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.dst}
{row.original.dstPort ? `:${row.original.dstPort}` : ""}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "app",
accessorFn: (r) => r.application ?? r.protoName,
header: () => <span className="text-xs font-medium text-muted-foreground">App</span>,
cell: ({ row }) => (
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
<span>{row.original.application ?? row.original.protoName}</span>
{row.original.category || row.original.service ? (
<span className="text-[10px] text-muted-foreground truncate">
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
</span>
) : null}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "proto",
accessorKey: "protoName",
header: () => <span className="text-xs font-medium text-muted-foreground">Proto</span>,
cell: ({ row }) => <span className="text-xs">{row.original.protoName}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "rate",
accessorFn: (r) => r.bps,
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "bytes",
accessorKey: "bytes",
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "iface",
accessorKey: "inIface",
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.inIface || "—"}</span>,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: cn(DATA_GRID_CELL_PAD_LAST) },
},
],
[],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row, i) => `${row.serverId}-${row.src}-${row.dst}-${row.proto}-${row.srcPort}-${row.dstPort}-${i}`,
})
return (
<DataGridShell
table={table}
recordCount={rows.length}
emptyMessage={
emptyHint
|| "Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
}
/>
)
}
export { TrafficFlowsDataGrid }
+244
View File
@@ -0,0 +1,244 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
import { EmptyState } from "@/components/empty-state"
import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users"
import {
CableIcon,
ChevronDownIcon,
ChevronRightIcon,
PencilIcon,
TrashIcon,
UsersIcon,
} from "lucide-react"
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold shrink-0 bg-gradient-to-br from-blue-500 to-violet-500",
!active && "opacity-50",
)}
>
{avatar}
</div>
)
}
interface UsersDataGridProps {
users: AppUser[]
serversCount: number
allSectionsCount?: number
isLoading?: boolean
onEdit: (user: AppUser) => void
onDelete: (user: AppUser) => void
}
function UsersDataGrid({
users,
serversCount,
allSectionsCount = ALL_SECTIONS.length,
isLoading,
onEdit,
onDelete,
}: UsersDataGridProps) {
const columns = useMemo<ColumnDef<AppUser>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
),
cell: ({ row }) => {
const u = row.original
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
)}
<AvatarCircle avatar={u.avatar} active={u.active} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{u.name}</p>
<p className="text-xs font-mono text-muted-foreground truncate">{u.email || u.login}</p>
</div>
</div>
)
},
meta: {
headerTitle: "Пользователь",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: AppUser) => (
<UsersExpandedDetail
user={row}
serversCount={serversCount}
allSectionsCount={allSectionsCount}
/>
),
},
},
{
id: "role",
accessorKey: "role",
header: ({ column }) => <DataGridSortHeader column={column} title="Роль" />,
cell: ({ row }) => (
<Badge
variant={row.original.role === "admin" ? "primary-light" : row.original.role === "operator" ? "info-light" : "outline"}
size="sm"
>
{ROLE_LABEL[row.original.role]}
</Badge>
),
meta: {
headerTitle: "Роль",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "last",
accessorKey: "last",
header: ({ column }) => <DataGridSortHeader column={column} title="Последний вход" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{row.original.last}</span>
),
meta: {
headerTitle: "Последний вход",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "ifaces",
accessorFn: (u) => u.bindings.length,
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<CableIcon className="size-3.5" />
{row.original.bindings.length}
</span>
),
meta: {
headerTitle: "Интерфейсы",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "status",
accessorKey: "active",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={cn(
"text-[11px] font-medium",
row.original.active ? "text-success" : "text-muted-foreground",
)}
>
{row.original.active ? "Активен" : "Заблокирован"}
</span>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const u = row.original
return (
<div
className="flex items-center justify-end gap-0.5"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(u)}
title="Редактировать"
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(u)}
title="Удалить"
>
<TrashIcon className="size-3.5" />
</Button>
</div>
)
},
meta: {
headerTitle: "Действия",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[allSectionsCount, onDelete, onEdit, serversCount],
)
const table = useReactTable({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (!isLoading && users.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет пользователей"
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={users.length}
isLoading={isLoading}
loadingMode="skeleton"
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
/>
)
}
export { UsersDataGrid, type UsersDataGridProps }
@@ -0,0 +1,132 @@
"use client"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import {
ALL_SECTIONS,
groupBindingsByServer,
IFACE_TYPE_LABEL,
summarizeUserAccess,
type AppUser,
type InterfaceType,
} from "@/lib/users"
import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react"
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
ether: "outline",
gre: "info-light",
wg: "success-light",
other: "secondary",
}
const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}
interface UsersExpandedDetailProps {
user: AppUser
serversCount: number
allSectionsCount?: number
}
function UsersExpandedDetail({
user,
serversCount,
allSectionsCount = ALL_SECTIONS.length,
}: UsersExpandedDetailProps) {
const access = summarizeUserAccess(user, serversCount, allSectionsCount)
const groups = groupBindingsByServer(user.bindings)
return (
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">
Разделы:{" "}
<span className="font-mono text-foreground">
{access.sectionsGranted}/{access.sectionsTotal}
</span>
</span>
<span className="text-muted-foreground">
Серверы:{" "}
<span className="font-mono text-foreground">
{access.serversGranted}/{access.serversTotal}
</span>
</span>
{access.writeKind === "full" ? (
<Badge variant="success-light" size="sm">Полный доступ</Badge>
) : access.writeKind === "none" ? (
<Badge variant="outline" size="sm">Только просмотр</Badge>
) : (
<span className="inline-flex flex-wrap items-center gap-1">
{access.writeSections.map((section) => (
<Badge key={section} variant="info-light" size="sm">{section}</Badge>
))}
</span>
)}
</div>
{groups.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
) : (
groups.map((g) => {
const items = g.types.flatMap((tg) => tg.items)
return (
<div key={g.serverId}>
<div className="flex items-center gap-3 mb-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
{g.serverName}
</p>
<Flag code={g.serverCountry} size={12} />
<span className="text-[11px] font-mono text-muted-foreground">{g.serverSite}</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{items.map((b) => {
const meta = TYPE_ICON[b.interfaceType]
const Icon = b.interfaceType === "wg" && b.peerPublicKey ? KeyRoundIcon : meta.icon
const iconClass = b.interfaceType === "wg" && b.peerPublicKey ? "text-success" : meta.className
return (
<div
key={b.id}
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
>
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", iconClass)}>
<Icon />
</IconTile>
<div className="min-w-0">
<p className="text-xs font-mono font-medium leading-tight truncate">
{b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)
? `${b.peerName || "peer"} · ${b.interfaceName}`
: b.interfaceName}
</p>
<div className="flex flex-wrap items-center gap-1 mt-0.5">
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
{IFACE_TYPE_LABEL[b.interfaceType]}
</Badge>
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
) : null}
{b.comment ? (
<span className="text-[10px] text-muted-foreground leading-tight truncate">
{b.comment}
</span>
) : null}
</div>
</div>
</div>
)
})}
</div>
</div>
)
})
)}
</div>
)
}
export { UsersExpandedDetail, TYPE_VARIANT, type UsersExpandedDetailProps }
+72 -41
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo } from "react"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
@@ -11,6 +11,7 @@ import {
import type { WireGuardInterface } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
@@ -33,7 +34,6 @@ import {
ChevronRightIcon,
CodeXmlIcon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
PowerIcon,
ShieldCheckIcon,
@@ -48,17 +48,38 @@ export interface WgIfaceWithServer extends WireGuardInterface {
interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[]
compactServer?: boolean
emptyAction?: ReactNode
onExport: (iface: WgIfaceWithServer) => void
onAddPeer?: (iface: WgIfaceWithServer) => void
onToggle?: (iface: WgIfaceWithServer) => void
onDelete?: (iface: WgIfaceWithServer) => void
onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void
onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void
}
function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
function WireguardDataGrid({
interfaces,
compactServer = false,
emptyAction,
onExport,
onAddPeer,
onToggle,
onDelete,
onDeletePeer,
onExportPeer,
}: WireguardDataGridProps) {
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
<DataGridSortHeader
column={column}
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
className="ml-1"
/>
),
cell: ({ row }) => {
const iface = row.original
@@ -76,15 +97,17 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
<span
className={cn(
"size-2 rounded-full shrink-0",
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
iface.status === "up" ? "bg-success animate-pulse" : "bg-destructive",
)}
/>
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} />
{iface.serverName}
</div>
{!compactServer ? (
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry || "UN"} size={12} />
{iface.serverName}
</div>
) : null}
<p className="sr-only">
{onlinePeers}/{iface.peers.length} пиров
</p>
@@ -93,11 +116,15 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
)
},
meta: {
headerTitle: "Интерфейс / Сервер",
headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => (
<WireGuardPeersDetail peers={row.peers} />
<WireGuardPeersDetail
peers={row.peers}
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
/>
),
},
},
@@ -136,7 +163,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return (
<span className="font-mono text-sm text-center block">
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
<span className="text-success">{onlinePeers}</span>
<span className="text-muted-foreground">/{iface.peers.length}</span>
</span>
)
@@ -152,16 +179,13 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border",
row.original.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}
<Badge
variant={row.original.status === "up" ? "success-light" : "destructive-light"}
size="sm"
className="font-mono"
>
{row.original.status === "up" ? "UP" : "DOWN"}
</span>
</Badge>
),
meta: {
headerTitle: "Статус",
@@ -203,26 +227,32 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onExport(iface)}>
<CodeXmlIcon className="size-4" />
Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить
Экспорт
</DropdownMenuItem>
{onAddPeer && (
<DropdownMenuItem onClick={() => onAddPeer(iface)}>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
)}
{onToggle && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onToggle(iface)}>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
</>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDelete(iface)}>
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -236,7 +266,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
},
},
],
[onExport],
[compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
)
const table = useReactTable({
@@ -254,7 +284,8 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
<EmptyState
icon={<ShieldCheckIcon className="size-4" />}
title="Нет WireGuard интерфейсов"
description="Добавьте первый интерфейс или проверьте поиск"
description="Добавьте первый интерфейс или сбросьте фильтры"
action={emptyAction}
className="border-0 py-16"
/>
)
@@ -2,10 +2,13 @@
import type { WireGuardPeer } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
ArrowDownIcon,
ArrowUpIcon,
CodeXmlIcon,
KeyRoundIcon,
Trash2Icon,
} from "lucide-react"
function fmtBytes(n: number | undefined): string {
@@ -21,7 +24,19 @@ function truncKey(key: string): string {
return `${key.slice(0, 8)}${key.slice(-8)}`
}
function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
function peerKey(peer: WireGuardPeer, index: number): string {
return peer.id ?? peer.rosId ?? peer.publicKey ?? String(index)
}
function WireGuardPeersDetail({
peers,
onDeletePeer,
onExportPeer,
}: {
peers: WireGuardPeer[]
onDeletePeer?: (peerId: string) => void
onExportPeer?: (peerId: string) => void
}) {
if (peers.length === 0) {
return (
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
@@ -32,48 +47,84 @@ function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
return (
<div className="border-t border-border/50">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
<span className="sr-only">Действия</span>
</div>
{peers.map((peer) => (
<div
key={peer.publicKey}
className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
>
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
{peers.map((peer, index) => {
const id = peerKey(peer, index)
return (
<div
key={id}
className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
{fmtBytes(peer.transferTx)}
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-success" : "text-muted-foreground",
)}
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-success" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-info" />
{fmtBytes(peer.transferTx)}
</span>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
<div className="flex items-center gap-1 justify-end">
{onExportPeer && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Экспорт peer .conf"
onClick={(e) => {
e.stopPropagation()
onExportPeer(id)
}}
>
<CodeXmlIcon className="size-3.5" />
</Button>
)}
{onDeletePeer && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить пира"
onClick={(e) => {
e.stopPropagation()
onDeletePeer(id)
}}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</div>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
</div>
))}
)
})}
</div>
)
}
@@ -0,0 +1,268 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { WireGuardPeer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail"
import {
ArrowDownIcon,
ArrowUpIcon,
CodeXmlIcon,
KeyRoundIcon,
Trash2Icon,
UsersIcon,
} from "lucide-react"
export interface WgPeerRow extends WireGuardPeer {
id: string
ifaceId: string
ifaceName: string
serverId: string
serverName: string
serverCountry: string
}
interface WireguardPeersGridProps {
peers: WgPeerRow[]
compactServer?: boolean
emptyAction?: ReactNode
onDeletePeer?: (row: WgPeerRow) => void
onExportPeer?: (row: WgPeerRow) => void
}
function WireguardPeersGrid({
peers,
compactServer = false,
emptyAction,
onDeletePeer,
onExportPeer,
}: WireguardPeersGridProps) {
const columns = useMemo<ColumnDef<WgPeerRow>[]>(() => {
const cols: ColumnDef<WgPeerRow>[] = [
{
id: "peer",
accessorKey: "publicKey",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пир" className="ml-1" />
),
cell: ({ row }) => {
const peer = row.original
return (
<div className="flex min-w-0 items-center gap-1.5">
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-mono text-xs" title={peer.publicKey}>
{peer.name || truncKey(peer.publicKey)}
</span>
</div>
)
},
meta: {
headerTitle: "Пир",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "iface",
accessorKey: "ifaceName",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.ifaceName}</span>
),
meta: {
headerTitle: "Интерфейс",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
]
if (!compactServer) {
cols.push({
id: "server",
accessorKey: "serverName",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
<Flag code={row.original.serverCountry || "UN"} size={12} />
{row.original.serverName}
</div>
),
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
})
}
cols.push(
{
id: "allowedIps",
accessorFn: (row) => row.allowedIps.join(", "),
header: ({ column }) => <DataGridSortHeader column={column} title="Allowed IPs" />,
cell: ({ row }) => (
<span className="block truncate font-mono text-xs text-muted-foreground">
{row.original.allowedIps.join(", ") || "—"}
</span>
),
meta: {
headerTitle: "Allowed IPs",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "handshake",
accessorKey: "latestHandshake",
header: ({ column }) => <DataGridSortHeader column={column} title="Handshake" />,
cell: ({ row }) => (
<span
className={cn(
"whitespace-nowrap font-mono text-[11px]",
row.original.latestHandshake ? "text-success" : "text-muted-foreground",
)}
>
{row.original.latestHandshake ?? "нет рукопожатия"}
</span>
),
meta: {
headerTitle: "Handshake",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "transfer",
header: () => (
<span className="text-xs font-medium text-muted-foreground">RX / TX</span>
),
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-success" />
{fmtBytes(row.original.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-info" />
{fmtBytes(row.original.transferTx)}
</span>
</div>
),
meta: {
headerTitle: "RX / TX",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "endpoint",
accessorKey: "endpoint",
header: ({ column }) => <DataGridSortHeader column={column} title="Endpoint" />,
cell: ({ row }) => (
<span className="font-mono text-[11px] text-muted-foreground">
{row.original.endpoint ?? "—"}
</span>
),
meta: {
headerTitle: "Endpoint",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
size: 72,
cell: ({ row }) => {
const peer = row.original
return (
<div className="flex justify-end gap-0.5">
{onExportPeer ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Экспорт peer .conf"
onClick={() => onExportPeer(peer)}
>
<CodeXmlIcon className="size-3.5" />
</Button>
) : null}
{onDeletePeer ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить пира"
onClick={() => onDeletePeer(peer)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
)
},
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
)
return cols
}, [compactServer, onDeletePeer, onExportPeer])
const table = useReactTable({
data: peers,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (peers.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет пиров"
description="Добавьте пира к интерфейсу или сбросьте поиск"
action={emptyAction}
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={peers.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row"),
}}
/>
)
}
export { WireguardPeersGrid, type WireguardPeersGridProps }

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