Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec43591a99 | ||
|
|
0e5fb065e2 | ||
|
|
5188b2aff2 | ||
|
|
cd1fd2c9d3 | ||
|
|
77425cca32 | ||
|
|
29d245cde3 | ||
|
|
7a491a325d | ||
|
|
db64621122 | ||
|
|
6332d83a12 | ||
|
|
3834c40aa8 | ||
|
|
90c8c393e5 | ||
|
|
cb799da13a | ||
|
|
e0ddb17539 | ||
|
|
2820683cba | ||
|
|
37167f78e3 | ||
|
|
cf68b59b3f | ||
|
|
5e512407e5 | ||
|
|
13889005f8 | ||
|
|
f0dc5acfd3 | ||
|
|
63bed28251 | ||
|
|
95dcd3df58 | ||
|
|
1e9312acbd | ||
|
|
5884bd8873 | ||
|
|
fc161506e7 | ||
|
|
b3e50a1f5f | ||
|
|
fe32c9313a |
@@ -38,8 +38,49 @@ jobs:
|
||||
node .ci/scripts/compute-release.mjs
|
||||
test -f .ci/release/release-manifest.json
|
||||
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Start PostgreSQL 18
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run -d --name mm-pg \
|
||||
-e POSTGRES_USER=mmapp \
|
||||
-e POSTGRES_PASSWORD=mmapp \
|
||||
-e POSTGRES_DB=mmapp \
|
||||
-p 5432:5432 \
|
||||
postgres:18-alpine
|
||||
for i in $(seq 1 40); do
|
||||
if docker exec mm-pg pg_isready -U mmapp -d mmapp; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL не поднялся"
|
||||
exit 1
|
||||
|
||||
- name: Install and test backend
|
||||
shell: bash
|
||||
env:
|
||||
DATABASE_URL: postgres://mmapp:mmapp@127.0.0.1:5432/mmapp
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run test --prefix backend
|
||||
|
||||
backend-image:
|
||||
needs: prepare-release
|
||||
needs:
|
||||
- prepare-release
|
||||
- backend-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|
||||
|-----------|------|------|----------------|--------------|
|
||||
| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
|
||||
| Backend | `backend/` | Fastify 5, TypeScript ESM, SQLite, Drizzle | 8000 | `…-backend` |
|
||||
| Backend | `backend/` | Fastify 5, TypeScript ESM, PostgreSQL 18, Drizzle | 8000 | `…-backend` |
|
||||
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
|
||||
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
|
||||
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
|
||||
@@ -43,11 +43,11 @@ flowchart TB
|
||||
Browser["Браузер"]
|
||||
NextDev["Next.js :3000"]
|
||||
FastifyDev["Fastify :8000"]
|
||||
SqliteDev["SQLite файл"]
|
||||
PostgresDev["PostgreSQL 18"]
|
||||
Contracts["@mmapp/contracts"]
|
||||
Browser --> NextDev
|
||||
Browser -->|"fetch /api, CORS"| FastifyDev
|
||||
FastifyDev --> SqliteDev
|
||||
FastifyDev --> PostgresDev
|
||||
Contracts --> NextDev
|
||||
Contracts --> FastifyDev
|
||||
end
|
||||
@@ -60,7 +60,8 @@ flowchart TB
|
||||
subgraph prod [Прод-сервер]
|
||||
FE["mmapp-frontend :3000"]
|
||||
BE["mmapp-backend :8000"]
|
||||
DBVol["volume backend-data /app/data"]
|
||||
PG["mmapp-postgres"]
|
||||
DBVol["volume sqlite ETL /app/data"]
|
||||
UPD["mmapp-updater"]
|
||||
Sock["/var/run/docker.sock"]
|
||||
Reg --> FE
|
||||
@@ -69,6 +70,7 @@ flowchart TB
|
||||
UPD --> Sock
|
||||
UPD --> FE
|
||||
UPD --> BE
|
||||
BE --> PG
|
||||
BE --> DBVol
|
||||
BrowserProd["Клиент"] --> FE
|
||||
BrowserProd -->|"live + backendUrl"| BE
|
||||
@@ -117,7 +119,7 @@ sequenceDiagram
|
||||
```
|
||||
|
||||
- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
|
||||
- Параллельные jobs: `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
|
||||
- Параллельные jobs: `backend-test` (PostgreSQL 18), `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
|
||||
- Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**.
|
||||
- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
|
||||
|
||||
@@ -125,7 +127,8 @@ sequenceDiagram
|
||||
|
||||
| Зависимость | Реализация |
|
||||
|-------------|------------|
|
||||
| SQLite | Не отдельный контейнер. Файл `mikrotik.db` в томе `backend-data` → `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db` в образе backend). |
|
||||
| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
|
||||
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При первом старте, если PG пустой, backend сам импортирует данные и ставит маркер. Повторный старт не копирует заново. |
|
||||
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
|
||||
|
||||
## Локальная разработка
|
||||
@@ -134,7 +137,8 @@ sequenceDiagram
|
||||
|
||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся (нужен только для одноразового boot-ETL из `mikrotik.db`).
|
||||
- Локально нужен PostgreSQL 18 (`deploy/docker-compose.postgres.yml` или `DATABASE_URL`).
|
||||
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
|
||||
|
||||
### Запуск
|
||||
@@ -161,7 +165,8 @@ Backend — скопировать `backend/.env.example` в `backend/.env`:
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|------------|--------------|------------|
|
||||
| `DATABASE_PATH` | `./mikrotik.db` | путь к файлу SQLite |
|
||||
| `DATABASE_URL` | `postgres://mmapp:mmapp@127.0.0.1:5432/mmapp` | подключение к PostgreSQL |
|
||||
| `DATABASE_PATH` | `./mikrotik.db` | SQLite только для boot-ETL, если PG пустой |
|
||||
| `PORT` | `8000` | порт Fastify |
|
||||
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
|
||||
|
||||
@@ -179,12 +184,10 @@ Frontend — в репозитории нет корневого `.env.example`.
|
||||
npm run build -w @mmapp/contracts
|
||||
```
|
||||
|
||||
После изменения схем Drizzle:
|
||||
Схема PostgreSQL накатывается при старте backend из [`backend/drizzle/0000_postgresql.sql`](backend/drizzle/0000_postgresql.sql). Опциональный ETL:
|
||||
|
||||
```bash
|
||||
npm --prefix backend run db:generate
|
||||
npm --prefix backend run db:migrate
|
||||
npm --prefix backend run db:studio
|
||||
npm --prefix backend run db:migrate-from-sqlite
|
||||
```
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
@@ -281,7 +284,8 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
|
||||
|
||||
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|
||||
|--------|------------------|----------------|----------------------|------|-----------|
|
||||
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data` → `/app/data` | `unless-stopped` |
|
||||
| postgres | `mmapp-postgres` | `postgres:18-alpine` | нет (внутренняя сеть) | `mmapp-pgdata` | `unless-stopped` |
|
||||
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | sqlite ETL → `/app/data` | `unless-stopped` |
|
||||
| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
|
||||
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `unless-stopped` |
|
||||
|
||||
@@ -297,11 +301,14 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
|
||||
|
||||
### Переменные окружения (прод)
|
||||
|
||||
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`, `DATABASE_PATH=/app/data/mikrotik.db`; в compose обычно переопределяют только CORS):
|
||||
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`; в compose задают `DATABASE_URL`, `DATABASE_PATH` для ETL и CORS):
|
||||
|
||||
| Переменная | Источник в compose | Назначение |
|
||||
|------------|-------------------|------------|
|
||||
| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
|
||||
| `DATABASE_URL` | `postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp` | PostgreSQL |
|
||||
| `DATABASE_PATH` | `/app/data/mikrotik.db` | SQLite на томе для одноразового ETL |
|
||||
| `POSTGRES_PASSWORD` | обязателен | пароль пользователя `mmapp` |
|
||||
|
||||
**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
|
||||
|
||||
@@ -312,7 +319,7 @@ SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md
|
||||
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
|
||||
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
|
||||
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
|
||||
| `HEALTH_TIMEOUT_SECONDS` | `120` | ожидание HTTP health |
|
||||
| `HEALTH_TIMEOUT_SECONDS` | `600` | ожидание HTTP health (первый boot с ETL может занять минуты) |
|
||||
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
|
||||
| `REGISTRY` | `git.shx.one` | registry для `docker login` |
|
||||
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
|
||||
@@ -516,18 +523,20 @@ docker compose up -d --force-recreate
|
||||
### Откат
|
||||
|
||||
- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
|
||||
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы SQLite** updater не выполняет — нужен отдельный backup тома `backend-data` / файла `mikrotik.db`.
|
||||
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы PostgreSQL** updater не выполняет — нужен `pg_dump` / restore или том `mmapp-pgdata`.
|
||||
|
||||
### Резервное копирование SQLite
|
||||
### Резервное копирование PostgreSQL
|
||||
|
||||
Том `backend-data` (или `mmapp-backend-data` при явном `docker volume create`). Пример остановки backend для консистентной копии:
|
||||
Том `mmapp-pgdata` и (до cleanup) sqlite `mikrotik.db` на `/app/data`. Из UI: Настройки → «Скачать бэкап» (`pg_dump -Fc`). С хоста:
|
||||
|
||||
```bash
|
||||
docker stop mmapp-backend
|
||||
docker run --rm -v backend-data:/data -v $(pwd):/backup alpine tar czf /backup/mikrotik-db-backup.tar.gz -C /data .
|
||||
docker start mmapp-backend
|
||||
docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
||||
```
|
||||
|
||||
Восстановление: UI «Восстановить из файла» или `pg_restore --clean --if-exists -d …`.
|
||||
|
||||
Том sqlite после успешного ETL и smoke **не удаляйте сразу** — оставьте как fallback, затем уберите `mikrotik.db`, когда маркер `data_migration.sqlite_imported_at` подтверждён.
|
||||
|
||||
### Отладка
|
||||
|
||||
| Действие | Команда |
|
||||
|
||||
@@ -2846,7 +2846,7 @@ export default function AlertsPage() {
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-3 pb-3 text-[11px] text-muted-foreground leading-relaxed space-y-2 border-t border-border/60 pt-2">
|
||||
<p>
|
||||
Движок читает последние сэмплы из SQLite (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
|
||||
Движок читает последние сэмплы из PostgreSQL (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
|
||||
срабатывает только если цепочка состояний совпадает с текстом правила.
|
||||
</p>
|
||||
<ul className="list-disc pl-4 space-y-1.5">
|
||||
|
||||
+34
-23
@@ -8,8 +8,7 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import type { Backup, Server } from "@/lib/data"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -343,27 +342,39 @@ export default function BackupsPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
|
||||
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
|
||||
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
|
||||
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-center gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка бэкапов"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего бэкапов",
|
||||
value: backupList.length,
|
||||
icon: <HardDriveIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
label: "Авто",
|
||||
value: autoCount,
|
||||
icon: <ClockIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "manual",
|
||||
label: "Вручную",
|
||||
value: manualCount,
|
||||
icon: <PlusIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверов охвачено",
|
||||
value: serverCount,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
|
||||
|
||||
+69
-33
@@ -10,6 +10,7 @@ import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||
XIcon, AlertCircleIcon,
|
||||
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
@@ -463,22 +464,39 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* summary row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
|
||||
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
||||
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка префиксов BGP"
|
||||
items={[
|
||||
{
|
||||
id: "rx",
|
||||
label: "Всего префиксов",
|
||||
value: fmtNum(totalRx),
|
||||
icon: <DownloadIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
label: "Активных маршрутов",
|
||||
value: fmtNum(totalActive),
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "ebgp",
|
||||
label: "eBGP сессий",
|
||||
value: ebgpSessions,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "ibgp",
|
||||
label: "iBGP сессий",
|
||||
value: ibgpSessions,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
||||
{/* prefixes by peer — horizontal bar chart */}
|
||||
@@ -722,22 +740,40 @@ export default function BgpPage() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Сессий всего", value: sessions.length, color: "" },
|
||||
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка BGP"
|
||||
items={[
|
||||
{
|
||||
id: "sessions",
|
||||
label: "Сессий всего",
|
||||
value: sessions.length,
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "established",
|
||||
label: "Established",
|
||||
value: established,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "not-estab",
|
||||
label: "Не установлено",
|
||||
value: notEstab,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: notEstab > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "prefixes",
|
||||
label: "Получено префиксов",
|
||||
value: fmtNum(totalRx),
|
||||
icon: <DownloadIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* alert: not-established sessions */}
|
||||
{notEstab > 0 && (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ── */}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { routerContainers, servers } from "@/lib/data"
|
||||
import type { RouterContainer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -286,27 +286,40 @@ export default function ContainersPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка контейнеров"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: routerContainers.length,
|
||||
icon: <BoxIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "running",
|
||||
label: "Running",
|
||||
value: running,
|
||||
icon: <PlayIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "stopped",
|
||||
label: "Stopped",
|
||||
value: stopped,
|
||||
icon: <StopCircleIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "errors",
|
||||
label: "Ошибок",
|
||||
value: errors,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: "text-destructive",
|
||||
variant: errors > 0 ? "destructive" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -6,8 +6,7 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import {
|
||||
@@ -55,6 +54,7 @@ import {
|
||||
type SchedulerJobGridRow,
|
||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
@@ -220,7 +220,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</Alert>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Запись в SQLite на{" "}
|
||||
Запись в PostgreSQL на{" "}
|
||||
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
{" "}— строки GRE и BGP для движка оповещений.
|
||||
</p>
|
||||
@@ -347,7 +347,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Снимок на{" "}
|
||||
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
. Данные для правил берутся из SQLite после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).
|
||||
. Данные для правил берутся из PostgreSQL после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).
|
||||
</p>
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
@@ -967,13 +967,13 @@ export default function DataCollectionPage() {
|
||||
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||
? "По сохранённым задачам планировщика"
|
||||
: "По переключателям на этой странице",
|
||||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
||||
icon: <CalendarClockIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Сейчас выполняется",
|
||||
value: String(runningJobsCount),
|
||||
sub: "Фоновые прогоны планировщика",
|
||||
icon: <LoaderCircleIcon className="size-4 text-amber-500" />,
|
||||
icon: <LoaderCircleIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Трафик — последний сбор",
|
||||
@@ -982,16 +982,16 @@ export default function DataCollectionPage() {
|
||||
: "—",
|
||||
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||||
icon: trafficCollector?.lastError ? (
|
||||
<XCircleIcon className="size-4 text-destructive" />
|
||||
<XCircleIcon className="size-4" />
|
||||
) : (
|
||||
<CheckCircleIcon className="size-4 text-emerald-500" />
|
||||
<CheckCircleIcon className="size-4" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Журнал (в списке)",
|
||||
value: String(schedulerRuns.length),
|
||||
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
|
||||
icon: <DatabaseIcon className="size-4 text-sky-500" />,
|
||||
icon: <DatabaseIcon className="size-4" />,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1064,22 +1064,32 @@ export default function DataCollectionPage() {
|
||||
|
||||
{isLive && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка сбора данных"
|
||||
items={stats.map((s, i) => ({
|
||||
id: `dc-${i}`,
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
hint: s.sub,
|
||||
icon: s.icon,
|
||||
iconClassName:
|
||||
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||
? "text-destructive"
|
||||
: s.label === "Сейчас выполняется" && runningJobsCount > 0
|
||||
? "text-warning"
|
||||
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||
? "text-destructive"
|
||||
: s.label === "Трафик — последний сбор"
|
||||
? "text-success"
|
||||
: "text-muted-foreground",
|
||||
variant:
|
||||
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||
? "destructive" as const
|
||||
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||
? "warning" as const
|
||||
: "default" as const,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<DataPageCard>
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
@@ -1201,9 +1211,11 @@ export default function DataCollectionPage() {
|
||||
</div>
|
||||
</DataPageCard>
|
||||
|
||||
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
|
||||
|
||||
<OpsPanel
|
||||
title="Журнал прогонов"
|
||||
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||
description="Таблица `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||
headerRight={
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
||||
|
||||
+133
-137
@@ -36,6 +36,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { toast } from "sonner"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1401,6 +1403,25 @@ export default function FiltersPage() {
|
||||
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
||||
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
|
||||
|
||||
const filterRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
allServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
meta: String(rulesets.find((r) => r.serverId === s.id)?.rules.length ?? 0),
|
||||
}))
|
||||
), [allServers, rulesets])
|
||||
|
||||
const handleSelectServer = useCallback((id: string) => {
|
||||
setSelectedServerId(id)
|
||||
setSearch("")
|
||||
}, [])
|
||||
|
||||
const currentRules = useMemo(
|
||||
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
|
||||
[rulesets, selectedServerId],
|
||||
@@ -1566,142 +1587,117 @@ export default function FiltersPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
||||
actions={
|
||||
<>
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncFromRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация Router → БД"
|
||||
>
|
||||
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncToRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация БД → Router"
|
||||
>
|
||||
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchRouterCompare()}
|
||||
disabled={syncBusy !== null || routerCompareLoading}
|
||||
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{routerCompareLoading ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
Сверить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||
<FileCodeIcon className="size-4" />RouterOS
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setCopyOpen(true)}
|
||||
disabled={currentRules.length === 0}
|
||||
title="Копировать правила на другой сервер"
|
||||
>
|
||||
<CopyIcon className="size-4" />Копировать
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />Новое правило
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLive && liveLoadState === "error" && (
|
||||
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── summary bar + server chips (same pattern as monitoring) ── */}
|
||||
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">Всего правил</span>
|
||||
<span className="font-semibold tabular-nums">{totalRules}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
||||
if (bhTotal === 0) return null
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
|
||||
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
|
||||
⊘ {bhTotal} blackhole
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
|
||||
<div className="w-px h-4 bg-border mx-1 shrink-0" />
|
||||
|
||||
{allServers.map(s => {
|
||||
const count = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0
|
||||
const active = selectedServerId === s.id
|
||||
return (
|
||||
<button key={s.id}
|
||||
onClick={() => { setSelectedServerId(s.id); setSearch("") }}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
||||
active
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
!s.enabled && !active && "opacity-40",
|
||||
)}>
|
||||
<StatusDot status={s.status} />
|
||||
<Flag code={s.country} size={12} />
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<TypeChip type={s.type} />
|
||||
<span className={cn(
|
||||
"tabular-nums font-semibold",
|
||||
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
|
||||
)}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── toolbar ── */}
|
||||
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
|
||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
|
||||
value={search} onChange={e => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground ml-auto">
|
||||
{filteredRules.length !== currentRules.length
|
||||
? `${filteredRules.length} из ${currentRules.length} правил`
|
||||
: `${currentRules.length} правил`
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={filterRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncFromRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация Router → БД"
|
||||
>
|
||||
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncToRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация БД → Router"
|
||||
>
|
||||
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchRouterCompare()}
|
||||
disabled={syncBusy !== null || routerCompareLoading}
|
||||
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{routerCompareLoading ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
Сверить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||
<FileCodeIcon className="size-4" />RouterOS
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setCopyOpen(true)}
|
||||
disabled={currentRules.length === 0}
|
||||
title="Копировать правила на другой сервер"
|
||||
>
|
||||
<CopyIcon className="size-4" />Копировать
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />Новое правило
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── main content ── */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
/>
|
||||
}
|
||||
banner={
|
||||
<>
|
||||
{isLive && liveLoadState === "error" && (
|
||||
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
|
||||
</div>
|
||||
)}
|
||||
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
|
||||
value={search} onChange={e => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">Всего правил</span>
|
||||
<span className="font-semibold tabular-nums">{totalRules}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
||||
if (bhTotal === 0) return null
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
|
||||
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
|
||||
⊘ {bhTotal} blackhole
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
<p className="text-xs text-muted-foreground ml-auto">
|
||||
{filteredRules.length !== currentRules.length
|
||||
? `${filteredRules.length} из ${currentRules.length} правил`
|
||||
: `${currentRules.length} правил`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
|
||||
@@ -1817,7 +1813,7 @@ export default function FiltersPage() {
|
||||
</DataPageCard>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<RuleSheet
|
||||
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
|
||||
@@ -1858,6 +1854,6 @@ export default function FiltersPage() {
|
||||
recRoutesByServer={recRoutesByServer}
|
||||
ensureRecursiveFor={ensureRecursiveRoutes}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+905
-154
File diff suppressed because it is too large
Load Diff
+108
-64
@@ -14,7 +14,7 @@ import { requestJson } from "@/shared/api/http-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {
|
||||
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||
@@ -35,6 +34,8 @@ import {
|
||||
DatabaseIcon,
|
||||
} from "lucide-react"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -241,6 +242,7 @@ export default function GrePage() {
|
||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
|
||||
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||||
const [poolOpen, setPoolOpen] = useState(false)
|
||||
@@ -293,6 +295,25 @@ export default function GrePage() {
|
||||
[isLive, displayTunnels],
|
||||
)
|
||||
|
||||
const greRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
|
||||
}))
|
||||
), [displayServers, displayTunnels])
|
||||
|
||||
const scopedTunnels = useMemo(() => {
|
||||
if (selectedServerId === ALL_SERVERS_ID) return displayTunnels
|
||||
return displayTunnels.filter((t) => t.serverId === selectedServerId)
|
||||
}, [displayTunnels, selectedServerId])
|
||||
|
||||
const serverById = useMemo(
|
||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||
[displayServers],
|
||||
@@ -341,7 +362,7 @@ export default function GrePage() {
|
||||
}, [dataError])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return displayTunnels.filter((t) => {
|
||||
return scopedTunnels.filter((t) => {
|
||||
if (tabFilter === "up" && t.status !== "up") return false
|
||||
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||
if (tabFilter === "plain" && t.ipsec) return false
|
||||
@@ -354,16 +375,18 @@ export default function GrePage() {
|
||||
serverById[t.serverId]?.name.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [tabFilter, search, displayTunnels, serverById])
|
||||
}, [tabFilter, search, scopedTunnels, serverById])
|
||||
|
||||
const upCount = displayTunnels.filter((t) => t.status === "up").length
|
||||
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
|
||||
const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||
const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length
|
||||
|
||||
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||||
{ value: "all", label: "Все", count: displayTunnels.length },
|
||||
{ value: "up", label: "Активные", count: upCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
||||
{ value: "all", label: "Все", count: scopedTunnels.length },
|
||||
{ value: "up", label: "Активные", count: scopedUpCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
|
||||
]
|
||||
|
||||
const greExportCode = useMemo(
|
||||
@@ -372,39 +395,48 @@ export default function GrePage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || dataLoading}
|
||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={greRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && dataLoading && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || dataLoading}
|
||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Legacy banner */}
|
||||
@@ -421,27 +453,39 @@ export default function GrePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка GRE"
|
||||
items={[
|
||||
{
|
||||
id: "tunnels",
|
||||
label: "Всего туннелей",
|
||||
value: displayTunnels.length,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активно",
|
||||
value: upCount,
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "ipsec",
|
||||
label: "Защищены IPsec",
|
||||
value: ipsecCount,
|
||||
icon: <LockIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "pools",
|
||||
label: "IP-пулов",
|
||||
value: displayPools.length,
|
||||
icon: <DatabaseIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Page tabs */}
|
||||
<div className="flex items-center gap-1 border-b">
|
||||
@@ -545,7 +589,7 @@ export default function GrePage() {
|
||||
</div>
|
||||
</OpsPanel>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||||
<CodeExportSheet
|
||||
@@ -801,6 +845,6 @@ export default function GrePage() {
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+752
-20
File diff suppressed because it is too large
Load Diff
+150
-133
@@ -7,17 +7,20 @@ import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-da
|
||||
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
|
||||
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
||||
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
||||
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
@@ -707,7 +710,7 @@ function InterfacesTab({
|
||||
}, [grouped, isLive])
|
||||
|
||||
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
|
||||
const canOptimizeLive = isLive && filterServerId !== "all"
|
||||
const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID
|
||||
const uniqueLiveFallbackOpt = useMemo(() => {
|
||||
const out: Record<string, number> = {}
|
||||
const byRouter: Record<string, OspfItem[]> = {}
|
||||
@@ -921,20 +924,33 @@ function NeighborsTab({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Всего соседей", value: neighbors.length, color: "" },
|
||||
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
|
||||
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка соседей OSPF"
|
||||
items={[
|
||||
{
|
||||
id: "neighbors",
|
||||
label: "Всего соседей",
|
||||
value: neighbors.length,
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "full",
|
||||
label: "Full",
|
||||
value: fullCount,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "not-full",
|
||||
label: "Не Full",
|
||||
value: neighbors.length - fullCount,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: neighbors.length - fullCount > 0 ? "warning" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{graphNodes.length > 0 && (
|
||||
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
|
||||
@@ -1044,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Сессий BFD", value: sessions.length, color: "" },
|
||||
{ label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" },
|
||||
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
|
||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка BFD"
|
||||
items={[
|
||||
{
|
||||
id: "sessions",
|
||||
label: "Сессий BFD",
|
||||
value: sessions.length,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Up",
|
||||
value: upCount,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "down",
|
||||
label: "Down / Admin",
|
||||
value: downCount,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
|
||||
variant: downCount > 0 ? "destructive" : "default",
|
||||
},
|
||||
{
|
||||
id: "init",
|
||||
label: "Init / другие",
|
||||
value: initCount,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: initCount > 0 ? "warning" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
@@ -1096,7 +1132,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
|
||||
|
||||
export default function OspfPage() {
|
||||
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
||||
const [filterServerId, setFilterServerId] = useState<string>("all")
|
||||
const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
|
||||
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
@@ -1218,9 +1254,26 @@ export default function OspfPage() {
|
||||
}, [items, neighbors, bfdSessions])
|
||||
|
||||
// ── filtered display data ─────────────────────────────────────────────────────
|
||||
const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId)
|
||||
const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
||||
const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
|
||||
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
|
||||
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
||||
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
|
||||
|
||||
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
ospfServers.map((s) => {
|
||||
const counts = serverCounts[s.id]
|
||||
const host = s.label.replace(/^mt-/, "")
|
||||
return {
|
||||
id: s.id,
|
||||
name: host,
|
||||
host,
|
||||
site: s.site,
|
||||
country: s.country || undefined,
|
||||
meta: counts
|
||||
? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}`
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
), [ospfServers, serverCounts])
|
||||
|
||||
// Graph always shows full topology (highlight is handled by node click inside tab)
|
||||
// KPIs reflect the current filter
|
||||
@@ -1229,91 +1282,45 @@ export default function OspfPage() {
|
||||
const totalAreas = new Set(displayItems.map(i => i.area)).size
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === t.id
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||
)}>
|
||||
{t.icon}{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── server filter chips (same pattern as Filters page) ─────────────── */}
|
||||
{ospfServers.length > 0 && (
|
||||
<div className="border-b bg-muted/20 px-6 py-2.5 flex items-center gap-2 flex-wrap shrink-0">
|
||||
{/* "All" chip */}
|
||||
<button
|
||||
onClick={() => setFilterServerId("all")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
||||
filterServerId === "all"
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
)}>
|
||||
Все серверы
|
||||
<span className={cn(
|
||||
"tabular-nums font-semibold",
|
||||
filterServerId === "all" ? "" : "text-foreground/60",
|
||||
)}>{items.length}</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-border shrink-0" />
|
||||
|
||||
{ospfServers.map(s => {
|
||||
const counts = serverCounts[s.id]
|
||||
const active = filterServerId === s.id
|
||||
return (
|
||||
<button key={s.id} onClick={() => setFilterServerId(s.id)}
|
||||
<ServerRailLayout
|
||||
items={ospfRailItems}
|
||||
selectedId={filterServerId}
|
||||
onSelect={setFilterServerId}
|
||||
showAll
|
||||
allCount={ospfServers.length}
|
||||
loading={isLive && loading && ospfServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
banner={
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
||||
active
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === t.id
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||
)}>
|
||||
{s.country ? <Flag code={s.country} size={12} /> : null}
|
||||
{s.site && (
|
||||
<span className={cn(
|
||||
"inline-block px-1 py-0 rounded text-[9px] font-bold leading-4",
|
||||
active
|
||||
? "bg-white/20"
|
||||
: "bg-muted-foreground/15 text-foreground/70",
|
||||
)}>{s.site}</span>
|
||||
)}
|
||||
<span className="font-mono">{s.label.replace(/^mt-/, "")}</span>
|
||||
{counts && (
|
||||
<span className={cn(
|
||||
"tabular-nums text-[10px]",
|
||||
active ? "opacity-80" : "text-foreground/50",
|
||||
)}>
|
||||
{counts.neighbors}n · {counts.ifaces}i
|
||||
{counts.bfd > 0 && ` · ${counts.bfd}b`}
|
||||
</span>
|
||||
)}
|
||||
{t.icon}{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* data source banner */}
|
||||
@@ -1346,21 +1353,32 @@ export default function OspfPage() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Роутеров", value: totalRouters },
|
||||
{ label: "Интерфейсов", value: totalInterfaces },
|
||||
{ label: "Зон (Area)", value: totalAreas },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка OSPF"
|
||||
items={[
|
||||
{
|
||||
id: "routers",
|
||||
label: "Роутеров",
|
||||
value: totalRouters,
|
||||
icon: <RouterIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Интерфейсов",
|
||||
value: totalInterfaces,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "areas",
|
||||
label: "Зон (Area)",
|
||||
value: totalAreas,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{activeTab === "interfaces" && (
|
||||
<InterfacesTab
|
||||
@@ -1384,7 +1402,6 @@ export default function OspfPage() {
|
||||
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
+53
-32
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+56
-808
File diff suppressed because it is too large
Load Diff
@@ -16,18 +16,12 @@ import {
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
ServerTileRail,
|
||||
type ServerTileItem,
|
||||
} from "@/components/server-tile-rail"
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, ServerIcon,
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -456,7 +450,6 @@ export default function TerminalPage() {
|
||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||
const [serversLoading, setServersLoading] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
||||
const [railOpen, setRailOpen] = useState(false)
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
@@ -516,6 +509,7 @@ export default function TerminalPage() {
|
||||
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,
|
||||
@@ -524,31 +518,15 @@ export default function TerminalPage() {
|
||||
}))
|
||||
}, [termServers])
|
||||
|
||||
function handleSelectServer(id: string) {
|
||||
const handleSelectServer = useCallback((id: string) => {
|
||||
setSelectedUid(id)
|
||||
setRefreshKey((k) => k + 1)
|
||||
setRailOpen(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const railHeaderRight = isLive
|
||||
? serversLoading
|
||||
? <Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
|
||||
: <Badge variant="success-light" size="xs">LIVE</Badge>
|
||||
const railHeaderRight = isLive && !serversLoading
|
||||
? <Badge variant="success-light" size="xs">LIVE</Badge>
|
||||
: undefined
|
||||
|
||||
const rail = (
|
||||
<ServerTileRail
|
||||
items={railItems}
|
||||
selectedId={selected?.uid ?? selectedUid}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
showType={false}
|
||||
headerRight={railHeaderRight}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
)
|
||||
|
||||
function QuickCmds() {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
||||
@@ -595,42 +573,39 @@ export default function TerminalPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="md:hidden"
|
||||
onClick={() => setRailOpen(true)}
|
||||
>
|
||||
<ServerIcon className="size-4" />
|
||||
{selected?.name ?? "Сервер"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRefreshKey((k) => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Переподключить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
|
||||
{rail}
|
||||
<QuickCmds />
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-hidden p-3 md:p-4">
|
||||
<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}
|
||||
@@ -643,30 +618,6 @@ export default function TerminalPage() {
|
||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={railOpen} onOpenChange={setRailOpen}>
|
||||
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
|
||||
<SheetHeader className="px-1 pt-1">
|
||||
<SheetTitle>Серверы</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<ServerTileRail
|
||||
items={railItems}
|
||||
selectedId={selected?.uid ?? selectedUid}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
showType={false}
|
||||
showHeader={false}
|
||||
headerRight={railHeaderRight}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
<QuickCmds />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
+695
-315
File diff suppressed because it is too large
Load Diff
+89
-46
@@ -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>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { UserSheet } from "@/components/users/user-sheet"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
ALL_SECTIONS,
|
||||
INIT_USERS,
|
||||
bindingDiffKey,
|
||||
userInitials,
|
||||
type AppUser,
|
||||
type AppUserForm,
|
||||
type UserServerOption,
|
||||
} from "@/lib/users"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import {
|
||||
createAppUser,
|
||||
createUserBinding,
|
||||
deleteAppUser,
|
||||
deleteUserBinding,
|
||||
listAppUsers,
|
||||
updateAppUser,
|
||||
} from "@/shared/api/users"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
CableIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
UserCheckIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
function toServerOptionsFromMock(): UserServerOption[] {
|
||||
return mockServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
}))
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
|
||||
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [list, srvs] = await Promise.all([
|
||||
listAppUsers(backendUrl),
|
||||
listServers(backendUrl),
|
||||
])
|
||||
setUsers(list)
|
||||
setServerOptions(srvs.map((s) => ({
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
})))
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [backendUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
setUsers(INIT_USERS)
|
||||
setServerOptions(toServerOptionsFromMock())
|
||||
return
|
||||
}
|
||||
void loadLive()
|
||||
}, [isLive, loadLive])
|
||||
|
||||
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
||||
const activeCount = users.filter((u) => u.active).length
|
||||
|
||||
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
||||
const nextKeys = new Set(next.map(bindingDiffKey))
|
||||
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
||||
for (const b of prev) {
|
||||
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||
await deleteUserBinding(backendUrl, userId, b.id)
|
||||
}
|
||||
}
|
||||
for (const b of next) {
|
||||
if (!prevKeys.has(bindingDiffKey(b))) {
|
||||
await createUserBinding(backendUrl, userId, {
|
||||
serverId: Number(b.serverId),
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey,
|
||||
peerName: b.peerName,
|
||||
comment: b.comment,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (form: AppUserForm) => {
|
||||
if (!isLive) {
|
||||
if (!editUser) {
|
||||
const id = `u${Date.now()}`
|
||||
const created: AppUser = {
|
||||
id,
|
||||
...form,
|
||||
last: "только что",
|
||||
avatar: userInitials(form.name),
|
||||
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
|
||||
}
|
||||
setUsers((prev) => [...prev, created])
|
||||
} else {
|
||||
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||
}
|
||||
setSheetOpen(false)
|
||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (!editUser) {
|
||||
const created = await createAppUser(backendUrl, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(created.id, form.bindings, [])
|
||||
await loadLive()
|
||||
} else {
|
||||
await updateAppUser(backendUrl, editUser.id, {
|
||||
name: form.name,
|
||||
login: form.login,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
avatar: userInitials(form.name),
|
||||
sections: form.sections,
|
||||
servers: form.servers,
|
||||
})
|
||||
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
|
||||
await loadLive()
|
||||
}
|
||||
setSheetOpen(false)
|
||||
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
if (!isLive) {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
|
||||
setDeleteTarget(null)
|
||||
toast.success("Пользователь удалён")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteAppUser(backendUrl, deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
await loadLive()
|
||||
toast.success("Пользователь удалён")
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||
actions={
|
||||
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Пригласить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
|
||||
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка пользователей"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Пользователи",
|
||||
value: users.length,
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
label: "Активные",
|
||||
value: activeCount,
|
||||
icon: <UserCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Привязанные ifaces",
|
||||
value: boundCount,
|
||||
icon: <CableIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
|
||||
<DataPageCard>
|
||||
<UsersDataGrid
|
||||
users={users}
|
||||
serversCount={serverOptions.length}
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
isLoading={loading}
|
||||
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
|
||||
<UserSheet
|
||||
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||
open={sheetOpen}
|
||||
user={editUser}
|
||||
users={users}
|
||||
servers={serverOptions}
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
saving={saving}
|
||||
onSave={(f) => { void handleSave(f) }}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<TrashIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+34
-24
@@ -4,14 +4,12 @@ import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { vxlanTunnels, servers } from "@/lib/data"
|
||||
import type { VxlanTunnel } from "@/lib/data"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
|
||||
} from "lucide-react"
|
||||
@@ -119,27 +117,39 @@ export default function VxlanPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
|
||||
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка VXLAN"
|
||||
items={[
|
||||
{
|
||||
id: "tunnels",
|
||||
label: "Туннелей",
|
||||
value: vxlanTunnels.length,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активных",
|
||||
value: upCount,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "vni",
|
||||
label: "Уникальных VNI",
|
||||
value: vnis,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверов",
|
||||
value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||
|
||||
@@ -23,12 +23,6 @@ import {
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -57,16 +51,16 @@ import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg
|
||||
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
|
||||
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
|
||||
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import {
|
||||
ALL_SERVERS_ID,
|
||||
ServerTileRail,
|
||||
type ServerTileItem,
|
||||
} from "@/components/server-tile-rail"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
||||
ServerIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon,
|
||||
Trash2Icon, CodeXmlIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
|
||||
@@ -186,7 +180,6 @@ export default function WireGuardPage() {
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
|
||||
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
|
||||
const [railOpen, setRailOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
@@ -338,6 +331,8 @@ export default function WireGuardPage() {
|
||||
return displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
@@ -347,16 +342,6 @@ export default function WireGuardPage() {
|
||||
}))
|
||||
}, [displayServers, displayIfaces])
|
||||
|
||||
const selectedLabel =
|
||||
effectiveServerId === ALL_SERVERS_ID
|
||||
? "Все серверы"
|
||||
: (displayServers.find((s) => s.id === effectiveServerId)?.name ?? "Сервер")
|
||||
|
||||
function handleSelectServer(id: string) {
|
||||
setSelectedServerId(id)
|
||||
setRailOpen(false)
|
||||
}
|
||||
|
||||
async function handleCreate(form: WgCreateFormState) {
|
||||
if (!isLive) {
|
||||
toast.info("Создание на роутер доступно только в live-режиме")
|
||||
@@ -543,59 +528,45 @@ export default function WireGuardPage() {
|
||||
</Button>
|
||||
)
|
||||
|
||||
const rail = (
|
||||
<ServerTileRail
|
||||
return (
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={handleSelectServer}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
allCount={displayIfaces.length}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="md:hidden"
|
||||
onClick={() => setRailOpen(true)}
|
||||
>
|
||||
<ServerIcon className="size-4" />
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
{isLive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => void loadLive()}
|
||||
>
|
||||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Обновить
|
||||
loading={isLive && loading && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
{isLive && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => void loadLive()}
|
||||
>
|
||||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Обновить
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Новый интерфейс
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="hidden min-h-0 w-60 shrink-0 p-3 pr-0 md:flex">
|
||||
{rail}
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Новый интерфейс
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка WireGuard"
|
||||
@@ -785,26 +756,7 @@ export default function WireGuardPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={railOpen} onOpenChange={setRailOpen}>
|
||||
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
|
||||
<SheetHeader className="px-1 pt-1">
|
||||
<SheetTitle>Серверы</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1">
|
||||
<ServerTileRail
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={handleSelectServer}
|
||||
allCount={displayIfaces.length}
|
||||
showHeader={false}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</ServerRailLayout>
|
||||
|
||||
<WgCreateSheet
|
||||
open={createOpen}
|
||||
@@ -870,6 +822,6 @@ export default function WireGuardPage() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Path to SQLite database file
|
||||
# PostgreSQL connection string
|
||||
DATABASE_URL=postgres://mmapp:mmapp@127.0.0.1:5432/mmapp
|
||||
|
||||
# SQLite file used only for one-shot boot ETL (empty PG + existing mikrotik.db)
|
||||
DATABASE_PATH=./mikrotik.db
|
||||
# SQLITE_IMPORT_FULL_HISTORY=true
|
||||
|
||||
# Port for the Fastify server
|
||||
PORT=8000
|
||||
|
||||
+9
-1
@@ -47,8 +47,16 @@ COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||
COPY --from=build /app/backend/dist ./backend/dist
|
||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
||||
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||
RUN mkdir -p /app/data
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
|
||||
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client-18 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /app/data
|
||||
EXPOSE 8000
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
|
||||
@@ -6,9 +6,9 @@ config()
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_PATH ?? "./mikrotik.db",
|
||||
url: process.env.DATABASE_URL ?? "postgres://mmapp:mmapp@127.0.0.1:5432/mmapp",
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
CREATE TABLE `server_snapshots` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`server_id` integer NOT NULL,
|
||||
`polled_at` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`latency_ms` real,
|
||||
`ros_version` text,
|
||||
`board_name` text,
|
||||
`uptime` text,
|
||||
`cpu_load` integer,
|
||||
`free_memory` integer,
|
||||
`total_memory` integer,
|
||||
`identity_name` text,
|
||||
`raw_interfaces` text,
|
||||
`raw_ip_addresses` text,
|
||||
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `servers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text DEFAULT '' NOT NULL,
|
||||
`host` text NOT NULL,
|
||||
`port` integer DEFAULT 443 NOT NULL,
|
||||
`username` text DEFAULT 'admin' NOT NULL,
|
||||
`password` text DEFAULT '' NOT NULL,
|
||||
`use_ssl` integer DEFAULT true NOT NULL,
|
||||
`verify_ssl` integer DEFAULT false NOT NULL,
|
||||
`type` text DEFAULT 'home-router' NOT NULL,
|
||||
`site` text DEFAULT '' NOT NULL,
|
||||
`country` text DEFAULT '' NOT NULL,
|
||||
`asn` text DEFAULT '' NOT NULL,
|
||||
`comment` text DEFAULT '' NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
`updated_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,626 @@
|
||||
-- MikrotikManager PostgreSQL 18 schema (not 1:1 SQLite).
|
||||
-- Applied once at backend boot via src/db/migrate.ts
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
type TEXT NOT NULL DEFAULT 'home-router'
|
||||
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||
site TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
asn TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||
wan_uplinks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id BIGINT,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||
last_datagram_at TIMESTAMPTZ,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||
peers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||
server_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
last_source_finished_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_migration (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
sqlite_imported_at TIMESTAMPTZ,
|
||||
sqlite_path TEXT,
|
||||
sqlite_sha256 TEXT,
|
||||
report_json JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
polled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||
latency_ms DOUBLE PRECISION,
|
||||
ros_version TEXT,
|
||||
board_name TEXT,
|
||||
uptime TEXT,
|
||||
cpu_load INTEGER,
|
||||
free_memory BIGINT,
|
||||
total_memory BIGINT,
|
||||
identity_name TEXT,
|
||||
raw_interfaces JSONB,
|
||||
raw_ip_addresses JSONB,
|
||||
PRIMARY KEY (id, polled_at)
|
||||
) PARTITION BY RANGE (polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_polled_brin ON server_snapshots USING BRIN (polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time ON traffic_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_sampled_brin ON traffic_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
ok BOOLEAN NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
error TEXT,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id ON servers_rest_ping_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_brin ON servers_rest_ping_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
src TEXT NOT NULL,
|
||||
dst TEXT NOT NULL,
|
||||
proto INTEGER NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
out_iface TEXT NOT NULL DEFAULT '',
|
||||
next_hop TEXT NOT NULL DEFAULT '',
|
||||
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_bucket_brin ON flow_buckets USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||
conversations INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_stats_brin ON flow_minute_stats USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_brin ON flow_minute_dims USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, dim, key)
|
||||
) PARTITION BY RANGE (day);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
asn INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||
asn INTEGER PRIMARY KEY,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time ON uptime_probe_samples(probe_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_brin ON uptime_probe_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
board_name TEXT NOT NULL DEFAULT '',
|
||||
ros_version TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time ON uptime_resource_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_brin ON uptime_resource_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_tx_avg_mbps DOUBLE PRECISION,
|
||||
last_rx_avg_mbps DOUBLE PRECISION,
|
||||
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||
last_error TEXT,
|
||||
last_ping_rtt_ms INTEGER,
|
||||
last_ping_loss_pct INTEGER,
|
||||
last_ping_at TIMESTAMPTZ,
|
||||
last_ping_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps DOUBLE PRECISION,
|
||||
rx_avg_mbps DOUBLE PRECISION,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names JSONB NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown_override TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
condition TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||
confirm_stability_sec INTEGER,
|
||||
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||
recovery_stability_sec INTEGER,
|
||||
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
target TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
condition_line TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
last_payload_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
payload_hash TEXT NOT NULL,
|
||||
since_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT,
|
||||
group_id TEXT,
|
||||
rule_name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
message TEXT NOT NULL,
|
||||
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
fired_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_key TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||
error TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
result_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||
event_type TEXT NOT NULL,
|
||||
source_module TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
payload_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TIMESTAMPTZ,
|
||||
sections_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
servers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB NOT NULL,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_brin ON internet_path_snapshots USING BRIN (sampled_at);
|
||||
|
||||
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"version": "7",
|
||||
"when": 1777572014210,
|
||||
"tag": "0000_living_xorn",
|
||||
"tag": "0000_postgresql",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "MikroTik Manager backend — Fastify + Drizzle + SQLite",
|
||||
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
@@ -11,8 +11,14 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
|
||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts"
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
|
||||
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/pg-schema.test.ts",
|
||||
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
@@ -25,12 +31,14 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"pg": "^8.23.0",
|
||||
"undici": "^8.1.0",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/pg": "^8.23.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jose": "^6.2.11",
|
||||
"pino-pretty": "^13.1.3",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Database from "better-sqlite3"
|
||||
import fs from "node:fs"
|
||||
|
||||
const path = process.argv[2] ?? "./mikrotik.db"
|
||||
if (!fs.existsSync(path)) {
|
||||
console.error("NO_SQLITE", path)
|
||||
process.exit(2)
|
||||
}
|
||||
const st = fs.statSync(path)
|
||||
const db = new Database(path, { readonly: true, fileMustExist: true })
|
||||
const tables = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
|
||||
.all()
|
||||
console.log("FILE_BYTES", st.size)
|
||||
console.log("TABLES", tables.length)
|
||||
for (const t of tables) {
|
||||
const c = db.prepare(`SELECT COUNT(*) AS c FROM "${t.name}"`).get()
|
||||
console.log(`COUNT\t${t.name}\t${c.c}`)
|
||||
}
|
||||
try {
|
||||
const d = db.prepare(`SELECT COUNT(*) AS c FROM (
|
||||
SELECT dedupe_key FROM alert_outbox GROUP BY dedupe_key HAVING COUNT(*) > 1
|
||||
)`).get()
|
||||
console.log("DUP_DEDUPE_KEYS", d.c)
|
||||
} catch (e) {
|
||||
console.log("DUP_DEDUPE_ERR", e instanceof Error ? e.message : e)
|
||||
}
|
||||
for (const [label, sql] of [
|
||||
["ORPHAN_FLOW_MINUTE", `SELECT COUNT(*) c FROM flow_minute_stats s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
|
||||
["ORPHAN_FLOW_BUCKETS", `SELECT COUNT(*) c FROM flow_buckets s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
|
||||
["ORPHAN_BINDINGS", `SELECT COUNT(*) c FROM user_interface_bindings s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
|
||||
]) {
|
||||
try {
|
||||
console.log(label, db.prepare(sql).get().c)
|
||||
} catch (e) {
|
||||
console.log(label + "_ERR", e instanceof Error ? e.message : e)
|
||||
}
|
||||
}
|
||||
db.close()
|
||||
@@ -11,7 +11,9 @@ function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||
const isProd = process.env.NODE_ENV === "production"
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().default("postgres://mmapp:mmapp@127.0.0.1:5432/mmapp"),
|
||||
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
||||
SQLITE_IMPORT_FULL_HISTORY: z.boolean().default(false),
|
||||
PORT: z.coerce.number().int().positive().default(8000),
|
||||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||
AUTH_REQUIRED: z.boolean().default(false),
|
||||
@@ -21,7 +23,9 @@ const envSchema = z.object({
|
||||
})
|
||||
|
||||
const raw = {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DATABASE_PATH: process.env.DATABASE_PATH,
|
||||
SQLITE_IMPORT_FULL_HISTORY: boolEnv(process.env.SQLITE_IMPORT_FULL_HISTORY, false),
|
||||
PORT: process.env.PORT,
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
@@ -53,7 +57,9 @@ if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
|
||||
}
|
||||
|
||||
export const env = {
|
||||
DATABASE_URL: parsed.data.DATABASE_URL,
|
||||
DATABASE_PATH: parsed.data.DATABASE_PATH,
|
||||
sqliteImportFullHistory: parsed.data.SQLITE_IMPORT_FULL_HISTORY,
|
||||
PORT: parsed.data.PORT,
|
||||
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
|
||||
authRequired: parsed.data.AUTH_REQUIRED,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { pool } from "./index.js"
|
||||
import { applySqlMigrations } from "./migrate.js"
|
||||
import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js"
|
||||
import { importSqliteToPostgres, shouldImportSqlite } from "./sqlite-import.js"
|
||||
import { env } from "../config.js"
|
||||
|
||||
const ETL_LOCK = 8723101
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
await pool.query("SELECT 1")
|
||||
await applySqlMigrations(pool)
|
||||
await ensurePartitionsAround(pool)
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock($1)", [ETL_LOCK])
|
||||
if (await shouldImportSqlite(pool, env.DATABASE_PATH)) {
|
||||
console.log(`SQLite → PostgreSQL: импорт ${env.DATABASE_PATH}`)
|
||||
const report = await importSqliteToPostgres(pool, env.DATABASE_PATH, { strict: true })
|
||||
console.log(
|
||||
`SQLite → PostgreSQL: готово за ${report.durationMs}ms, таблиц ${Object.keys(report.tables).length}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_unlock($1)", [ETL_LOCK])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
client.release()
|
||||
}
|
||||
await dropExpiredPartitions(pool)
|
||||
await ensurePartitionsAround(pool)
|
||||
}
|
||||
+52
-706
@@ -1,725 +1,71 @@
|
||||
import Database from "better-sqlite3"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
type SqliteHandle = InstanceType<typeof Database>
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3"
|
||||
import { isMainThread } from "node:worker_threads"
|
||||
import pg from "pg"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { env } from "../config.js"
|
||||
import * as schema from "./schema.js"
|
||||
import { bindSql } from "./sql-bind.js"
|
||||
|
||||
const sqlite = new Database(env.DATABASE_PATH)
|
||||
const INT8_OID = 20
|
||||
const DATE_OID = 1082
|
||||
|
||||
// WAL mode for better concurrent read performance
|
||||
sqlite.pragma("journal_mode = WAL")
|
||||
sqlite.pragma("foreign_keys = ON")
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
use_ssl INTEGER NOT NULL DEFAULT 1,
|
||||
verify_ssl INTEGER NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL DEFAULT 'home-router',
|
||||
site TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
asn TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||
wan_uplinks TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
pg.types.setTypeParser(INT8_OID, (val) => {
|
||||
const n = Number(val)
|
||||
return Number.isSafeInteger(n) ? n : val
|
||||
})
|
||||
pg.types.setTypeParser(DATE_OID, (val) => val)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
polled_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
latency_ms REAL,
|
||||
ros_version TEXT,
|
||||
board_name TEXT,
|
||||
uptime TEXT,
|
||||
cpu_load INTEGER,
|
||||
free_memory INTEGER,
|
||||
total_memory INTEGER,
|
||||
identity_name TEXT,
|
||||
raw_interfaces TEXT,
|
||||
raw_ip_addresses TEXT,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route',
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort
|
||||
ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort
|
||||
ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
interface_name TEXT NOT NULL,
|
||||
sampled_at TEXT NOT NULL,
|
||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
rx_bps INTEGER NOT NULL DEFAULT 0,
|
||||
tx_bps INTEGER NOT NULL DEFAULT 0,
|
||||
running INTEGER NOT NULL DEFAULT 0,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
|
||||
ON traffic_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
|
||||
ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id INTEGER NOT NULL,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
show_on_dashboard INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort
|
||||
ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
probe_id TEXT NOT NULL,
|
||||
sampled_at TEXT NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down',
|
||||
FOREIGN KEY (probe_id) REFERENCES uptime_probes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time
|
||||
ON uptime_probe_samples(probe_id, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
sampled_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory INTEGER NOT NULL DEFAULT 0,
|
||||
total_memory INTEGER NOT NULL DEFAULT 0,
|
||||
free_hdd_space INTEGER NOT NULL DEFAULT 0,
|
||||
total_hdd_space INTEGER NOT NULL DEFAULT 0,
|
||||
uptime_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
board_name TEXT NOT NULL DEFAULT '',
|
||||
ros_version TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time
|
||||
ON uptime_resource_samples(server_id, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id INTEGER NOT NULL,
|
||||
dst_server_id INTEGER NOT NULL,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp',
|
||||
direction TEXT NOT NULL DEFAULT 'both',
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_run_at TEXT,
|
||||
last_tx_avg_mbps REAL,
|
||||
last_rx_avg_mbps REAL,
|
||||
last_status TEXT,
|
||||
last_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort
|
||||
ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id INTEGER NOT NULL,
|
||||
dst_server_id INTEGER NOT NULL,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp',
|
||||
direction TEXT NOT NULL DEFAULT 'both',
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps REAL,
|
||||
rx_avg_mbps REAL,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done',
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at
|
||||
ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_key TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
result_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
|
||||
ON scheduler_runs(job_key, finished_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sampled_at TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled
|
||||
ON internet_path_snapshots(sampled_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
source_module TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
payload_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
sampled_at TEXT NOT NULL,
|
||||
ok INTEGER NOT NULL DEFAULT 0,
|
||||
latency_ms INTEGER,
|
||||
error TEXT,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id
|
||||
ON servers_rest_ping_samples(server_id, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_gre_tunnel_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sampled_at TEXT NOT NULL,
|
||||
target_label TEXT NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_gre_tunnel_samples_label_id
|
||||
ON alert_gre_tunnel_samples(target_label, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_bgp_peer_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sampled_at TEXT NOT NULL,
|
||||
peer_key TEXT NOT NULL,
|
||||
state TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_bgp_peer_samples_key_id
|
||||
ON alert_bgp_peer_samples(peer_key, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
server_id TEXT NOT NULL,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names TEXT NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily',
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc',
|
||||
server_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
last_run_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
condition TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||
recovery_mode TEXT NOT NULL DEFAULT 'always',
|
||||
recovery_stability_sec INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT,
|
||||
rule_name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
sent_ok INTEGER NOT NULL DEFAULT 1,
|
||||
fired_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
combine_mode TEXT NOT NULL DEFAULT 'any',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
cooldown_override TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL,
|
||||
condition_line TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
last_payload_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||
kind TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
payload_hash TEXT NOT NULL,
|
||||
since_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_destinations (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT 'telegram',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
telegram_chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'telegram',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
sent_at TEXT,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt
|
||||
ON alert_outbox(status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_dedupe
|
||||
ON alert_outbox(dedupe_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
id INTEGER PRIMARY KEY,
|
||||
last_source_finished_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
|
||||
// Lightweight schema evolution for existing databases without migrations
|
||||
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
||||
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
||||
if (!hasCountryColumn) {
|
||||
sqlite.exec(`ALTER TABLE recursive_routes ADD COLUMN country TEXT NOT NULL DEFAULT ''`)
|
||||
export function createPool(max = 16): pg.Pool {
|
||||
return new pg.Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
max,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 8_000,
|
||||
statement_timeout: 120_000,
|
||||
})
|
||||
}
|
||||
|
||||
const uptimeProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_probes')`).all() as Array<{ name?: string }>
|
||||
const hasSrcInterfaceColumn = uptimeProbeCols.some((c) => c.name === "src_interface")
|
||||
if (!hasSrcInterfaceColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN src_interface TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
const hasShowOnDashboardColumn = uptimeProbeCols.some((c) => c.name === "show_on_dashboard")
|
||||
if (!hasShowOnDashboardColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 0`)
|
||||
}
|
||||
const hasProbeIntervalSecColumn = uptimeProbeCols.some((c) => c.name === "interval_sec")
|
||||
if (!hasProbeIntervalSecColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN interval_sec INTEGER NOT NULL DEFAULT 0`)
|
||||
export const pool = createPool(isMainThread ? 16 : 4)
|
||||
export const db = drizzle(pool, { schema })
|
||||
|
||||
export async function dbQuery<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[] | Record<string, unknown>,
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
const q = bindSql(text, params)
|
||||
return pool.query<T>(q)
|
||||
}
|
||||
|
||||
const uptimeSettingsCols = sqlite.prepare(`PRAGMA table_info('uptime_settings')`).all() as Array<{ name?: string }>
|
||||
const hasProbeIntervalColumn = uptimeSettingsCols.some((c) => c.name === "probe_interval_sec")
|
||||
if (!hasProbeIntervalColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN probe_interval_sec INTEGER NOT NULL DEFAULT 15`)
|
||||
}
|
||||
const hasSpeedIntervalColumn = uptimeSettingsCols.some((c) => c.name === "speed_interval_sec")
|
||||
if (!hasSpeedIntervalColumn) {
|
||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_interval_sec INTEGER NOT NULL DEFAULT 60`)
|
||||
export async function dbAll<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[] | Record<string, unknown>,
|
||||
): Promise<T[]> {
|
||||
const res = await dbQuery<T>(text, params)
|
||||
return res.rows
|
||||
}
|
||||
|
||||
const hasUptimeJobFlags = uptimeSettingsCols.some((c) => c.name === "resources_enabled")
|
||||
if (!hasUptimeJobFlags) {
|
||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN resources_enabled INTEGER NOT NULL DEFAULT 1`)
|
||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN ping_enabled INTEGER NOT NULL DEFAULT 1`)
|
||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_enabled INTEGER NOT NULL DEFAULT 1`)
|
||||
sqlite.exec(
|
||||
`UPDATE uptime_settings SET resources_enabled = enabled, ping_enabled = enabled, speed_enabled = enabled WHERE id = 1`,
|
||||
)
|
||||
export async function dbGet<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[] | Record<string, unknown>,
|
||||
): Promise<T | undefined> {
|
||||
const rows = await dbAll<T>(text, params)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
const uptimeSpeedProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_speed_probes')`).all() as Array<{ name?: string }>
|
||||
const ensureSpeedProbeCol = (name: string, ddl: string) => {
|
||||
if (!uptimeSpeedProbeCols.some((c) => c.name === name)) sqlite.exec(ddl)
|
||||
}
|
||||
ensureSpeedProbeCol("last_run_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_run_at TEXT`)
|
||||
ensureSpeedProbeCol("last_tx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_tx_avg_mbps REAL`)
|
||||
ensureSpeedProbeCol("last_rx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_rx_avg_mbps REAL`)
|
||||
ensureSpeedProbeCol("last_status", `ALTER TABLE uptime_speed_probes ADD COLUMN last_status TEXT`)
|
||||
ensureSpeedProbeCol("last_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_error TEXT`)
|
||||
ensureSpeedProbeCol("last_ping_rtt_ms", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_rtt_ms INTEGER`)
|
||||
ensureSpeedProbeCol("last_ping_loss_pct", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_loss_pct INTEGER`)
|
||||
ensureSpeedProbeCol("last_ping_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_at TEXT`)
|
||||
ensureSpeedProbeCol("last_ping_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_error TEXT`)
|
||||
|
||||
const schedulerRunCols = sqlite.prepare(`PRAGMA table_info('scheduler_runs')`).all() as Array<{ name?: string }>
|
||||
if (!schedulerRunCols.some((c) => c.name === "result_json")) {
|
||||
sqlite.exec(`ALTER TABLE scheduler_runs ADD COLUMN result_json TEXT`)
|
||||
}
|
||||
|
||||
const serverCols = sqlite.prepare(`PRAGMA table_info('servers')`).all() as Array<{ name?: string }>
|
||||
if (!serverCols.some((c) => c.name === "lan_subnet")) {
|
||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN lan_subnet TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
|
||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
|
||||
}
|
||||
|
||||
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
|
||||
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
|
||||
sqlite.exec(`ALTER TABLE alert_telegram_settings ADD COLUMN message_thread_id INTEGER`)
|
||||
}
|
||||
|
||||
const alertRulesCols = sqlite.prepare(`PRAGMA table_info('alert_rules')`).all() as Array<{ name?: string }>
|
||||
if (!alertRulesCols.some((c) => c.name === "group_id")) {
|
||||
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN group_id TEXT`)
|
||||
}
|
||||
if (!alertRulesCols.some((c) => c.name === "confirm_stability_sec")) {
|
||||
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN confirm_stability_sec INTEGER`)
|
||||
}
|
||||
if (!alertRulesCols.some((c) => c.name === "recovery_mode")) {
|
||||
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_mode TEXT NOT NULL DEFAULT 'always'`)
|
||||
}
|
||||
if (!alertRulesCols.some((c) => c.name === "recovery_stability_sec")) {
|
||||
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_stability_sec INTEGER`)
|
||||
}
|
||||
|
||||
const alertHistoryCols = sqlite.prepare(`PRAGMA table_info('alert_history')`).all() as Array<{ name?: string }>
|
||||
if (!alertHistoryCols.some((c) => c.name === "group_id")) {
|
||||
sqlite.exec(`ALTER TABLE alert_history ADD COLUMN group_id TEXT`)
|
||||
}
|
||||
|
||||
/** Одна строка target на правило из legacy-колонки `alert_rules.target` */
|
||||
sqlite.exec(`
|
||||
INSERT OR IGNORE INTO alert_rule_targets (id, rule_id, target, sort_index)
|
||||
SELECT 'rt-' || id || '-0', id, target, 0 FROM alert_rules
|
||||
WHERE id NOT IN (SELECT rule_id FROM alert_rule_targets)
|
||||
`)
|
||||
|
||||
/** Одна строка условия из legacy `alert_rules.condition` */
|
||||
sqlite.exec(`
|
||||
INSERT OR IGNORE INTO alert_rule_conditions (id, rule_id, condition_line, sort_index)
|
||||
SELECT 'rc-' || id || '-0', id, condition, 0 FROM alert_rules
|
||||
WHERE id NOT IN (SELECT rule_id FROM alert_rule_conditions)
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
|
||||
SELECT 1, 1, 30, 14
|
||||
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
||||
SELECT 1, 1, 15, 14
|
||||
WHERE NOT EXISTS (SELECT 1 FROM uptime_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO evobgp_settings (id, base_url, api_key, enabled)
|
||||
SELECT 1, '', '', 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM evobgp_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO servers_api_ping_settings (id, enabled, interval_sec)
|
||||
SELECT 1, 0, 120
|
||||
WHERE NOT EXISTS (SELECT 1 FROM servers_api_ping_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO internet_path_settings (id, enabled, interval_sec, retention_days)
|
||||
SELECT 1, 1, 300, 14
|
||||
WHERE NOT EXISTS (SELECT 1 FROM internet_path_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO alert_telegram_settings (id, bot_token, chat_id)
|
||||
SELECT 1, '', ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO acme_settings (id, directory_url, cloudflare_api_token, default_zone_id, account_private_key)
|
||||
SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
||||
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
||||
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
||||
}
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO certificate_renew_settings (id, enabled, interval_sec, renew_before_days)
|
||||
SELECT 1, 1, 21600, 30
|
||||
WHERE NOT EXISTS (SELECT 1 FROM certificate_renew_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO backup_schedule_settings (id, enabled, frequency, hour, minute, week_day, month_day, keep_count, format, server_ids_json)
|
||||
SELECT 1, 1, 'daily', 3, 0, 0, 1, 7, 'rsc', '[]'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM backup_schedule_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
|
||||
SELECT 1, NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
|
||||
`)
|
||||
|
||||
const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number }
|
||||
if (backupEntryCount.c === 0) {
|
||||
const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json")
|
||||
if (existsSync(legacyIndexPath)) {
|
||||
export async function withAdvisoryLock<T>(key: number, fn: () => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock($1)", [key])
|
||||
return await fn()
|
||||
} finally {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
const insert = sqlite.prepare(`
|
||||
INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at)
|
||||
VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt)
|
||||
`)
|
||||
for (const row of parsed) {
|
||||
if (!row || typeof row !== "object") continue
|
||||
const item = row as Record<string, unknown>
|
||||
const id = String(item.id ?? "").trim()
|
||||
const filename = String(item.filename ?? "").trim()
|
||||
if (!id || !filename) continue
|
||||
insert.run({
|
||||
id,
|
||||
serverId: String(item.serverId ?? ""),
|
||||
serverName: String(item.serverName ?? ""),
|
||||
filename,
|
||||
sizeBytes: Number(item.sizeBytes ?? 0) || 0,
|
||||
kind: item.kind === "auto" ? "auto" : "manual",
|
||||
notes: item.notes == null ? null : String(item.notes),
|
||||
createdAt: String(item.createdAt ?? new Date().toISOString()),
|
||||
})
|
||||
}
|
||||
}
|
||||
await client.query("SELECT pg_advisory_unlock($1)", [key])
|
||||
} catch {
|
||||
/* legacy index.json не читается — пропускаем */
|
||||
/* ignore */
|
||||
}
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
|
||||
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
||||
export const sqliteDatabase: SqliteHandle = sqlite
|
||||
export async function closePool(): Promise<void> {
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** JSONB from node-pg may already be an object; SQLite leftovers may be strings. */
|
||||
|
||||
export function parseJsonObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function parseJsonArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function toJsonb(value: unknown): unknown {
|
||||
if (value == null) return null
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Pool } from "pg"
|
||||
|
||||
const MIGRATION_ID = "0000_postgresql"
|
||||
|
||||
function migrationsDir(): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const candidates = [
|
||||
join(here, "..", "..", "drizzle"),
|
||||
join(process.cwd(), "drizzle"),
|
||||
join(process.cwd(), "backend", "drizzle"),
|
||||
]
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
readFileSync(join(dir, `${MIGRATION_ID}.sql`), "utf8")
|
||||
return dir
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
throw new Error("Не найден backend/drizzle/0000_postgresql.sql")
|
||||
}
|
||||
|
||||
export async function applySqlMigrations(pool: Pool): Promise<void> {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`)
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM schema_migrations WHERE id = $1`,
|
||||
[MIGRATION_ID],
|
||||
)
|
||||
if (rows.length > 0) return
|
||||
const sql = readFileSync(join(migrationsDir(), `${MIGRATION_ID}.sql`), "utf8")
|
||||
await pool.query(sql)
|
||||
await pool.query(`INSERT INTO schema_migrations (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, [
|
||||
MIGRATION_ID,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Pool } from "pg"
|
||||
|
||||
export type PartitionKind = "day" | "week" | "month"
|
||||
|
||||
export interface PartitionSpec {
|
||||
parent: string
|
||||
kind: PartitionKind
|
||||
keepDays: number
|
||||
}
|
||||
|
||||
export const PARTITION_SPECS: PartitionSpec[] = [
|
||||
{ parent: "flow_buckets", kind: "day", keepDays: 4 },
|
||||
{ parent: "flow_minute_stats", kind: "day", keepDays: 4 },
|
||||
{ parent: "flow_minute_dims", kind: "day", keepDays: 4 },
|
||||
{ parent: "flow_daily_dims", kind: "month", keepDays: 420 },
|
||||
{ parent: "traffic_samples", kind: "week", keepDays: 21 },
|
||||
{ parent: "servers_rest_ping_samples", kind: "week", keepDays: 35 },
|
||||
{ parent: "uptime_probe_samples", kind: "week", keepDays: 21 },
|
||||
{ parent: "uptime_resource_samples", kind: "week", keepDays: 21 },
|
||||
{ parent: "server_snapshots", kind: "week", keepDays: 21 },
|
||||
{ parent: "internet_path_snapshots", kind: "week", keepDays: 21 },
|
||||
]
|
||||
|
||||
function utcDate(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
||||
}
|
||||
|
||||
function addUtcDays(d: Date, n: number): Date {
|
||||
const x = utcDate(d)
|
||||
x.setUTCDate(x.getUTCDate() + n)
|
||||
return x
|
||||
}
|
||||
|
||||
function startOfWeekUtc(d: Date): Date {
|
||||
const x = utcDate(d)
|
||||
const day = x.getUTCDay() || 7
|
||||
x.setUTCDate(x.getUTCDate() - (day - 1))
|
||||
return x
|
||||
}
|
||||
|
||||
function startOfMonthUtc(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1))
|
||||
}
|
||||
|
||||
function fmtDay(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function partName(parent: string, kind: PartitionKind, start: Date): string {
|
||||
if (kind === "day") return `${parent}_d_${fmtDay(start).replaceAll("-", "")}`
|
||||
if (kind === "week") return `${parent}_w_${fmtDay(start).replaceAll("-", "")}`
|
||||
return `${parent}_m_${start.getUTCFullYear()}${String(start.getUTCMonth() + 1).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function rangeFor(kind: PartitionKind, ts: Date): { start: Date; end: Date } {
|
||||
if (kind === "day") {
|
||||
const start = utcDate(ts)
|
||||
return { start, end: addUtcDays(start, 1) }
|
||||
}
|
||||
if (kind === "week") {
|
||||
const start = startOfWeekUtc(ts)
|
||||
return { start, end: addUtcDays(start, 7) }
|
||||
}
|
||||
const start = startOfMonthUtc(ts)
|
||||
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1))
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export async function ensurePartitionFor(
|
||||
pool: Pool,
|
||||
parent: string,
|
||||
kind: PartitionKind,
|
||||
ts: Date,
|
||||
): Promise<string> {
|
||||
const { start, end } = rangeFor(kind, ts)
|
||||
const name = partName(parent, kind, start)
|
||||
const from = fmtDay(start)
|
||||
const to = fmtDay(end)
|
||||
await pool.query(
|
||||
`CREATE TABLE IF NOT EXISTS ${name} PARTITION OF ${parent} FOR VALUES FROM ('${from}') TO ('${to}')`,
|
||||
)
|
||||
return name
|
||||
}
|
||||
|
||||
export async function ensurePartitionsAround(pool: Pool, around = new Date()): Promise<void> {
|
||||
for (const spec of PARTITION_SPECS) {
|
||||
const daysAhead = spec.kind === "month" ? 40 : spec.kind === "week" ? 21 : 8
|
||||
const start = addUtcDays(around, -spec.keepDays)
|
||||
const end = addUtcDays(around, daysAhead)
|
||||
for (let t = new Date(start.getTime()); t < end; ) {
|
||||
await ensurePartitionFor(pool, spec.parent, spec.kind, t)
|
||||
if (spec.kind === "day") t = addUtcDays(t, 1)
|
||||
else if (spec.kind === "week") t = addUtcDays(t, 7)
|
||||
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function dropExpiredPartitions(pool: Pool, around = new Date()): Promise<void> {
|
||||
for (const spec of PARTITION_SPECS) {
|
||||
const cutoff = addUtcDays(around, -spec.keepDays)
|
||||
const { rows } = await pool.query<{ relname: string }>(
|
||||
`SELECT c.relname
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
WHERE p.relname = $1`,
|
||||
[spec.parent],
|
||||
)
|
||||
for (const row of rows) {
|
||||
const m = row.relname.match(/_([dwm])_(\d{8}|\d{6})$/)
|
||||
if (!m) continue
|
||||
const stamp = m[2]
|
||||
let start: Date
|
||||
if (stamp.length === 6) {
|
||||
start = new Date(Date.UTC(Number(stamp.slice(0, 4)), Number(stamp.slice(4, 6)) - 1, 1))
|
||||
} else {
|
||||
start = new Date(`${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T00:00:00Z`)
|
||||
}
|
||||
if (start < cutoff) {
|
||||
await pool.query(`DROP TABLE IF EXISTS ${row.relname}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function specForParent(parent: string): PartitionSpec | undefined {
|
||||
return PARTITION_SPECS.find((s) => s.parent === parent)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { withPgOrSkip } from "../test/pg.js"
|
||||
import { dbQuery, pool } from "./index.js"
|
||||
import { ensurePartitionFor } from "./partitions.js"
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
console.log("pg-schema.test.ts: skip")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ n: string }>(`SELECT COUNT(*)::text AS n FROM servers`)
|
||||
assert.ok(rows[0])
|
||||
}
|
||||
|
||||
{
|
||||
await dbQuery(`
|
||||
INSERT INTO servers (name, host) VALUES ('pg-schema-test', '127.0.0.1')
|
||||
`)
|
||||
const { rows } = await dbQuery<{ id: number }>(`SELECT id FROM servers WHERE name = 'pg-schema-test' LIMIT 1`)
|
||||
const id = rows[0]?.id
|
||||
assert.ok(id)
|
||||
|
||||
await ensurePartitionFor(pool, "flow_buckets", "day", new Date())
|
||||
const bucketAt = new Date().toISOString()
|
||||
await dbQuery(`
|
||||
INSERT INTO flow_buckets (
|
||||
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface
|
||||
) VALUES ($1, $2, '10.0.0.1', '8.8.8.8', 6, 1, 443, 10, 1, 'wg-flow')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [id, bucketAt])
|
||||
|
||||
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = $1`, [id])
|
||||
await dbQuery(`DELETE FROM servers WHERE id = $1`, [id])
|
||||
}
|
||||
|
||||
{
|
||||
await dbQuery(`
|
||||
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||
VALUES ('pg-test-1', 'pg-dedupe-key', '{}'::jsonb, now())
|
||||
ON CONFLICT (dedupe_key) DO NOTHING
|
||||
`)
|
||||
let threw = false
|
||||
try {
|
||||
await dbQuery(`
|
||||
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||
VALUES ('pg-test-2', 'pg-dedupe-key', '{}'::jsonb, now())
|
||||
`)
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
assert.equal(threw, true, "alert_outbox.dedupe_key UNIQUE")
|
||||
await dbQuery(`DELETE FROM alert_outbox WHERE dedupe_key = 'pg-dedupe-key'`)
|
||||
}
|
||||
|
||||
console.log("pg-schema.test.ts: ok")
|
||||
+420
-290
@@ -1,73 +1,83 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
real,
|
||||
sqliteTable,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
} from "drizzle-orm/sqlite-core"
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
function ts(name: string) {
|
||||
return timestamp(name, { withTimezone: true, mode: "string" as const })
|
||||
}
|
||||
|
||||
function idIdentity() {
|
||||
return bigint("id", { mode: "number" }).generatedByDefaultAsIdentity()
|
||||
}
|
||||
|
||||
function idSingleton() {
|
||||
return bigint("id", { mode: "number" }).primaryKey()
|
||||
}
|
||||
|
||||
function intPkRef() {
|
||||
return bigint("server_id", { mode: "number" }).notNull()
|
||||
}
|
||||
|
||||
// ── servers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const servers = sqliteTable("servers", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
export const servers = pgTable("servers", {
|
||||
id: idIdentity().primaryKey(),
|
||||
name: text("name").notNull().default(""),
|
||||
host: text("host").notNull(),
|
||||
port: integer("port").notNull().default(443),
|
||||
username: text("username").notNull().default("admin"),
|
||||
password: text("password").notNull().default(""),
|
||||
useSsl: integer("use_ssl", { mode: "boolean" }).notNull().default(true),
|
||||
verifySsl: integer("verify_ssl", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
// metadata set manually by user
|
||||
useSsl: boolean("use_ssl").notNull().default(true),
|
||||
verifySsl: boolean("verify_ssl").notNull().default(false),
|
||||
type: text("type", { enum: ["jump-host", "exit-node", "home-router"] })
|
||||
.notNull().default("home-router"),
|
||||
site: text("site").notNull().default(""),
|
||||
country: text("country").notNull().default(""),
|
||||
asn: text("asn").notNull().default(""),
|
||||
comment: text("comment").notNull().default(""),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
|
||||
/** Подсеть LAN (home-router), текст из формы */
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
lanSubnet: text("lan_subnet").notNull().default(""),
|
||||
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
|
||||
wanUplinks: text("wan_uplinks").notNull().default("[]"),
|
||||
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
wanUplinks: jsonb("wan_uplinks").notNull().default(sql`'[]'::jsonb`),
|
||||
mgmtTunnelIp: text("mgmt_tunnel_ip").notNull().default(""),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
// ── server_snapshots ───────────────────────────────────────────────────────────
|
||||
|
||||
export const serverSnapshots = sqliteTable("server_snapshots", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
polledAt: text("polled_at").notNull(),
|
||||
export const serverSnapshots = pgTable("server_snapshots", {
|
||||
id: idIdentity(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
polledAt: ts("polled_at").notNull(),
|
||||
status: text("status", { enum: ["online", "offline"] }).notNull(),
|
||||
latencyMs: real("latency_ms"),
|
||||
|
||||
// data from RouterOS
|
||||
latencyMs: doublePrecision("latency_ms"),
|
||||
rosVersion: text("ros_version"),
|
||||
boardName: text("board_name"),
|
||||
uptime: text("uptime"),
|
||||
cpuLoad: integer("cpu_load"),
|
||||
freeMemory: integer("free_memory"),
|
||||
totalMemory: integer("total_memory"),
|
||||
freeMemory: bigint("free_memory", { mode: "number" }),
|
||||
totalMemory: bigint("total_memory", { mode: "number" }),
|
||||
identityName: text("identity_name"),
|
||||
rawInterfaces: jsonb("raw_interfaces"),
|
||||
rawIpAddresses: jsonb("raw_ip_addresses"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.polledAt] }),
|
||||
index("idx_server_snapshots_server_time").on(t.serverId, t.polledAt),
|
||||
])
|
||||
|
||||
// raw JSON payloads for future use
|
||||
rawInterfaces: text("raw_interfaces"),
|
||||
rawIpAddresses: text("raw_ip_addresses"),
|
||||
})
|
||||
|
||||
// ── filter_rules ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const filterRules = sqliteTable("filter_rules", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
export const filterRules = pgTable("filter_rules", {
|
||||
id: idIdentity().primaryKey(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
community: text("community").notNull(),
|
||||
communityName: text("community_name"),
|
||||
@@ -75,17 +85,15 @@ export const filterRules = sqliteTable("filter_rules", {
|
||||
gateway: text("gateway").notNull().default(""),
|
||||
gatewayTunnelId: text("gateway_tunnel_id").notNull().default(""),
|
||||
description: text("description").notNull().default(""),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_filter_rules_server_sort").on(t.serverId, t.sortOrder),
|
||||
])
|
||||
|
||||
// ── recursive_routes ───────────────────────────────────────────────────────────
|
||||
|
||||
export const recursiveRoutes = sqliteTable("recursive_routes", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
export const recursiveRoutes = pgTable("recursive_routes", {
|
||||
id: idIdentity().primaryKey(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
dstAddress: text("dst_address").notNull(),
|
||||
gateway: text("gateway").notNull(),
|
||||
@@ -96,236 +104,317 @@ export const recursiveRoutes = sqliteTable("recursive_routes", {
|
||||
checkGateway: text("check_gateway").notNull().default(""),
|
||||
country: text("country").notNull().default(""),
|
||||
comment: text("comment").notNull().default(""),
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
disabled: boolean("disabled").notNull().default(false),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_recursive_routes_server_sort").on(t.serverId, t.sortOrder),
|
||||
])
|
||||
|
||||
// ── traffic collection settings ────────────────────────────────────────────────
|
||||
|
||||
export const trafficSettings = sqliteTable("traffic_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
export const trafficSettings = pgTable("traffic_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(30),
|
||||
retentionDays: integer("retention_days").notNull().default(14),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
/** Настройки фоновой проверки доступности RouterOS REST API по серверам каталога. */
|
||||
export const serversApiPingSettings = sqliteTable("servers_api_ping_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||
export const serversApiPingSettings = pgTable("servers_api_ping_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
intervalSec: integer("interval_sec").notNull().default(120),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
/** Сырые пробы REST `/system/identity` по серверам — для UI сбора и для `buildSignalSnapshot` (алерты). */
|
||||
export const serversRestPingSamples = sqliteTable("servers_rest_ping_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
ok: integer("ok", { mode: "boolean" }).notNull(),
|
||||
export const serversRestPingSamples = pgTable("servers_rest_ping_samples", {
|
||||
id: idIdentity(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
ok: boolean("ok").notNull(),
|
||||
latencyMs: integer("latency_ms"),
|
||||
error: text("error"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_servers_rest_ping_samples_server_id").on(t.serverId, t.sampledAt),
|
||||
])
|
||||
|
||||
export const trafficFlowSettings = pgTable("traffic_flow_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
collectorIp: text("collector_ip").notNull().default("10.255.254.1"),
|
||||
flowListenPort: integer("flow_listen_port").notNull().default(4739),
|
||||
wgListenPort: integer("wg_listen_port").notNull().default(51821),
|
||||
prefix: text("prefix").notNull().default("10.255.254.0/24"),
|
||||
publicEndpoint: text("public_endpoint").notNull().default(""),
|
||||
hostPublicKey: text("host_public_key").notNull().default(""),
|
||||
hostPrivateKey: text("host_private_key").notNull().default(""),
|
||||
hubServerId: bigint("hub_server_id", { mode: "number" }),
|
||||
retentionHours: integer("retention_hours").notNull().default(24),
|
||||
topN: integer("top_n").notNull().default(200),
|
||||
mapServiceMinSharePct: doublePrecision("map_service_min_share_pct").notNull().default(5),
|
||||
lastDatagramAt: ts("last_datagram_at"),
|
||||
lastExporterIp: text("last_exporter_ip"),
|
||||
lastError: text("last_error"),
|
||||
packetsReceived: bigint("packets_received", { mode: "number" }).notNull().default(0),
|
||||
peersJson: jsonb("peers_json").notNull().default(sql`'[]'::jsonb`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
/** Сырые снимки GRE для алертов — пишет джоба `gre_bgp`. */
|
||||
export const alertGreTunnelSamples = sqliteTable("alert_gre_tunnel_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
targetLabel: text("target_label").notNull(),
|
||||
status: text("status").notNull(),
|
||||
export const flowMinuteStats = pgTable("flow_minute_stats", {
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: ts("bucket_at").notNull(),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
uniqueSrc: integer("unique_src").notNull().default(0),
|
||||
uniqueDst: integer("unique_dst").notNull().default(0),
|
||||
conversations: integer("conversations").notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.bucketAt] }),
|
||||
])
|
||||
|
||||
export const flowMinuteDims = pgTable("flow_minute_dims", {
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: ts("bucket_at").notNull(),
|
||||
dim: text("dim").notNull(),
|
||||
key: text("key").notNull(),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.bucketAt, t.dim, t.key] }),
|
||||
index("idx_flow_minute_dims_time").on(t.bucketAt, t.dim),
|
||||
])
|
||||
|
||||
export const flowDailyDims = pgTable("flow_daily_dims", {
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
day: date("day", { mode: "string" }).notNull(),
|
||||
dim: text("dim").notNull(),
|
||||
key: text("key").notNull(),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.serverId, t.day, t.dim, t.key] }),
|
||||
index("idx_flow_daily_dims_day").on(t.day, t.dim),
|
||||
])
|
||||
|
||||
export const flowBuckets = pgTable("flow_buckets", {
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: ts("bucket_at").notNull(),
|
||||
src: text("src").notNull(),
|
||||
dst: text("dst").notNull(),
|
||||
proto: integer("proto").notNull().default(0),
|
||||
srcPort: integer("src_port").notNull().default(0),
|
||||
dstPort: integer("dst_port").notNull().default(0),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
inIface: text("in_iface").notNull().default(""),
|
||||
outIface: text("out_iface").notNull().default(""),
|
||||
nextHop: text("next_hop").notNull().default(""),
|
||||
flowStartMs: bigint("flow_start_ms", { mode: "number" }).notNull().default(0),
|
||||
flowEndMs: bigint("flow_end_ms", { mode: "number" }).notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({
|
||||
name: "flow_buckets_pkey",
|
||||
columns: [t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort, t.inIface],
|
||||
}),
|
||||
index("idx_flow_buckets_server_time").on(t.serverId, t.bucketAt),
|
||||
])
|
||||
|
||||
export const flowIpMeta = pgTable("flow_ip_meta", {
|
||||
prefix: text("prefix").primaryKey(),
|
||||
asn: integer("asn").notNull().default(0),
|
||||
country: text("country").notNull().default(""),
|
||||
lat: doublePrecision("lat"),
|
||||
lng: doublePrecision("lng"),
|
||||
holder: text("holder").notNull().default(""),
|
||||
ok: integer("ok").notNull().default(1),
|
||||
fetchedAt: ts("fetched_at").notNull(),
|
||||
})
|
||||
|
||||
/** Сырые снимки BGP-сессий для алертов — пишет джоба `gre_bgp`. */
|
||||
export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
peerKey: text("peer_key").notNull(),
|
||||
state: text("state").notNull(),
|
||||
export const flowAsnMeta = pgTable("flow_asn_meta", {
|
||||
asn: integer("asn").primaryKey(),
|
||||
holder: text("holder").notNull().default(""),
|
||||
fetchedAt: ts("fetched_at").notNull(),
|
||||
})
|
||||
|
||||
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
|
||||
|
||||
export const trafficSamples = sqliteTable("traffic_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
export const trafficSamples = pgTable("traffic_samples", {
|
||||
id: idIdentity(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
interfaceName: text("interface_name").notNull(),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
rxBytes: integer("rx_bytes").notNull().default(0),
|
||||
txBytes: integer("tx_bytes").notNull().default(0),
|
||||
rxBps: integer("rx_bps").notNull().default(0),
|
||||
txBps: integer("tx_bps").notNull().default(0),
|
||||
running: integer("running", { mode: "boolean" }).notNull().default(false),
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
})
|
||||
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
rxBytes: bigint("rx_bytes", { mode: "number" }).notNull().default(0),
|
||||
txBytes: bigint("tx_bytes", { mode: "number" }).notNull().default(0),
|
||||
rxBps: bigint("rx_bps", { mode: "number" }).notNull().default(0),
|
||||
txBps: bigint("tx_bps", { mode: "number" }).notNull().default(0),
|
||||
running: boolean("running").notNull().default(false),
|
||||
disabled: boolean("disabled").notNull().default(false),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_traffic_samples_server_time").on(t.serverId, t.sampledAt),
|
||||
index("idx_traffic_samples_server_iface_time").on(t.serverId, t.interfaceName, t.sampledAt),
|
||||
])
|
||||
|
||||
// ── uptime monitor settings ────────────────────────────────────────────────────
|
||||
|
||||
export const uptimeSettings = sqliteTable("uptime_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
/** Устаревший агрегат: синхронизируется как resources ∨ ping ∨ speed (для совместимости). */
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
resourcesEnabled: integer("resources_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
pingEnabled: integer("ping_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
speedEnabled: integer("speed_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
export const uptimeSettings = pgTable("uptime_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
resourcesEnabled: boolean("resources_enabled").notNull().default(true),
|
||||
pingEnabled: boolean("ping_enabled").notNull().default(true),
|
||||
speedEnabled: boolean("speed_enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(15),
|
||||
probeIntervalSec: integer("probe_interval_sec").notNull().default(15),
|
||||
speedIntervalSec: integer("speed_interval_sec").notNull().default(60),
|
||||
retentionDays: integer("retention_days").notNull().default(14),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const uptimeProbes = sqliteTable("uptime_probes", {
|
||||
export const uptimeProbes = pgTable("uptime_probes", {
|
||||
id: text("id").primaryKey(),
|
||||
srcServerId: integer("src_server_id")
|
||||
.notNull()
|
||||
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcInterface: text("src_interface").notNull().default(""),
|
||||
name: text("name").notNull(),
|
||||
target: text("target").notNull(),
|
||||
probeFilter: text("probe_filter").notNull().default("—"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
/** 0 — брать глобальный probe_interval_sec из uptime_settings */
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(0),
|
||||
/** Выводить пробу в блоке «Активные пробы» на дашборде */
|
||||
showOnDashboard: integer("show_on_dashboard", { mode: "boolean" }).notNull().default(false),
|
||||
showOnDashboard: boolean("show_on_dashboard").notNull().default(false),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_uptime_probes_server_sort").on(t.srcServerId, t.sortOrder),
|
||||
])
|
||||
|
||||
export const uptimeProbeSamples = sqliteTable("uptime_probe_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
probeId: text("probe_id")
|
||||
.notNull()
|
||||
.references(() => uptimeProbes.id, { onDelete: "cascade" }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
export const uptimeProbeSamples = pgTable("uptime_probe_samples", {
|
||||
id: idIdentity(),
|
||||
probeId: text("probe_id").notNull().references(() => uptimeProbes.id, { onDelete: "cascade" }),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
rttMs: integer("rtt_ms"),
|
||||
lossPct: integer("loss_pct").notNull().default(0),
|
||||
status: text("status", { enum: ["up", "warn", "down"] }).notNull().default("down"),
|
||||
})
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_uptime_probe_samples_probe_time").on(t.probeId, t.sampledAt),
|
||||
])
|
||||
|
||||
export const uptimeResourceSamples = sqliteTable("uptime_resource_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
export const uptimeResourceSamples = pgTable("uptime_resource_samples", {
|
||||
id: idIdentity(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
status: text("status", { enum: ["online", "offline"] }).notNull().default("offline"),
|
||||
cpuLoad: integer("cpu_load").notNull().default(0),
|
||||
freeMemory: integer("free_memory").notNull().default(0),
|
||||
totalMemory: integer("total_memory").notNull().default(0),
|
||||
freeHddSpace: integer("free_hdd_space").notNull().default(0),
|
||||
totalHddSpace: integer("total_hdd_space").notNull().default(0),
|
||||
uptimeSeconds: integer("uptime_seconds").notNull().default(0),
|
||||
freeMemory: bigint("free_memory", { mode: "number" }).notNull().default(0),
|
||||
totalMemory: bigint("total_memory", { mode: "number" }).notNull().default(0),
|
||||
freeHddSpace: bigint("free_hdd_space", { mode: "number" }).notNull().default(0),
|
||||
totalHddSpace: bigint("total_hdd_space", { mode: "number" }).notNull().default(0),
|
||||
uptimeSeconds: bigint("uptime_seconds", { mode: "number" }).notNull().default(0),
|
||||
boardName: text("board_name").notNull().default(""),
|
||||
rosVersion: text("ros_version").notNull().default(""),
|
||||
})
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_uptime_resource_samples_server_time").on(t.serverId, t.sampledAt),
|
||||
])
|
||||
|
||||
export const uptimeSpeedProbes = sqliteTable("uptime_speed_probes", {
|
||||
export const uptimeSpeedProbes = pgTable("uptime_speed_probes", {
|
||||
id: text("id").primaryKey(),
|
||||
srcServerId: integer("src_server_id")
|
||||
.notNull()
|
||||
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
dstServerId: integer("dst_server_id")
|
||||
.notNull()
|
||||
dstServerId: bigint("dst_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcInterface: text("src_interface").notNull().default(""),
|
||||
dstInterface: text("dst_interface").notNull().default(""),
|
||||
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
|
||||
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
|
||||
durationSec: integer("duration_sec").notNull().default(10),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastRunAt: text("last_run_at"),
|
||||
lastTxAvgMbps: real("last_tx_avg_mbps"),
|
||||
lastRxAvgMbps: real("last_rx_avg_mbps"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
lastRunAt: ts("last_run_at"),
|
||||
lastTxAvgMbps: doublePrecision("last_tx_avg_mbps"),
|
||||
lastRxAvgMbps: doublePrecision("last_rx_avg_mbps"),
|
||||
lastStatus: text("last_status", { enum: ["done", "error"] }),
|
||||
lastError: text("last_error"),
|
||||
lastPingRttMs: integer("last_ping_rtt_ms"),
|
||||
lastPingLossPct: integer("last_ping_loss_pct"),
|
||||
lastPingAt: text("last_ping_at"),
|
||||
lastPingAt: ts("last_ping_at"),
|
||||
lastPingError: text("last_ping_error"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_uptime_speed_probes_src_sort").on(t.srcServerId, t.sortOrder),
|
||||
])
|
||||
|
||||
// ── EvoBGP integration (URL + API key на сервере) ─────────────────────────────
|
||||
|
||||
export const evobgpSettings = sqliteTable("evobgp_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
export const evobgpSettings = pgTable("evobgp_settings", {
|
||||
id: idSingleton(),
|
||||
baseUrl: text("base_url").notNull().default(""),
|
||||
apiKey: text("api_key").notNull().default(""),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
/** Единственная строка id=1: токен и чат Telegram для оповещений. */
|
||||
export const alertTelegramSettings = sqliteTable("alert_telegram_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
export const alertTelegramSettings = pgTable("alert_telegram_settings", {
|
||||
id: idSingleton(),
|
||||
botToken: text("bot_token").notNull().default(""),
|
||||
chatId: text("chat_id").notNull().default(""),
|
||||
/** Тема супергруппы (forum): `message_thread_id` в Bot API; NULL — общий чат */
|
||||
messageThreadId: integer("message_thread_id"),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const acmeSettings = sqliteTable("acme_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
export const acmeSettings = pgTable("acme_settings", {
|
||||
id: idSingleton(),
|
||||
directoryUrl: text("directory_url").notNull().default("https://acme-v02.api.letsencrypt.org/directory"),
|
||||
cloudflareApiToken: text("cloudflare_api_token").notNull().default(""),
|
||||
defaultZoneId: text("default_zone_id").notNull().default(""),
|
||||
accountPrivateKey: text("account_private_key").notNull().default(""),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
|
||||
export const certificateIssueJobs = pgTable("certificate_issue_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"),
|
||||
step: text("step").notNull().default("queued"),
|
||||
source: text("source", { enum: ["manual", "scheduler"] }).notNull().default("manual"),
|
||||
serverId: text("server_id").notNull(),
|
||||
serverId: bigint("server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
certName: text("cert_name").notNull(),
|
||||
domainNames: text("domain_names").notNull(),
|
||||
domainNames: jsonb("domain_names").notNull(),
|
||||
keyType: text("key_type").notNull().default("rsa2048"),
|
||||
trustStore: text("trust_store").notNull().default("www,api"),
|
||||
requestedAt: text("requested_at").notNull().default(sql`(datetime('now'))`),
|
||||
startedAt: text("started_at"),
|
||||
finishedAt: text("finished_at"),
|
||||
requestedAt: ts("requested_at").notNull().defaultNow(),
|
||||
startedAt: ts("started_at"),
|
||||
finishedAt: ts("finished_at"),
|
||||
error: text("error"),
|
||||
})
|
||||
|
||||
export const certificateRenewSettings = sqliteTable("certificate_renew_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
export const certificateRenewSettings = pgTable("certificate_renew_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(21600),
|
||||
renewBeforeDays: integer("renew_before_days").notNull().default(30),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
export const backupScheduleSettings = pgTable("backup_schedule_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
frequency: text("frequency", { enum: ["daily", "weekly", "monthly"] }).notNull().default("daily"),
|
||||
hour: integer("hour").notNull().default(3),
|
||||
minute: integer("minute").notNull().default(0),
|
||||
@@ -333,167 +422,153 @@ export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
|
||||
monthDay: integer("month_day").notNull().default(1),
|
||||
keepCount: integer("keep_count").notNull().default(7),
|
||||
format: text("format", { enum: ["rsc", "backup"] }).notNull().default("rsc"),
|
||||
serverIdsJson: text("server_ids_json").notNull().default("[]"),
|
||||
lastRunAt: text("last_run_at"),
|
||||
serverIdsJson: jsonb("server_ids_json").notNull().default(sql`'[]'::jsonb`),
|
||||
lastRunAt: ts("last_run_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const backupEntries = sqliteTable("backup_entries", {
|
||||
export const backupEntries = pgTable("backup_entries", {
|
||||
id: text("id").primaryKey(),
|
||||
serverId: text("server_id").notNull(),
|
||||
serverId: bigint("server_id", { mode: "number" })
|
||||
.references(() => servers.id, { onDelete: "set null" }),
|
||||
serverName: text("server_name").notNull(),
|
||||
filename: text("filename").notNull(),
|
||||
sizeBytes: integer("size_bytes").notNull(),
|
||||
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
||||
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
|
||||
notes: text("notes"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
})
|
||||
createdAt: ts("created_at").notNull(),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_backup_entries_filename").on(t.filename),
|
||||
index("idx_backup_entries_server_created").on(t.serverId, t.createdAt),
|
||||
])
|
||||
|
||||
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
|
||||
export const alertGroups = sqliteTable("alert_groups", {
|
||||
export const alertGroups = pgTable("alert_groups", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
combineMode: text("combine_mode", { enum: ["any", "all"] }).notNull().default("any"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
/** Переопределение cooldown для агрегата группы; NULL — по умолчанию 5м */
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
cooldownOverride: text("cooldown_override", {
|
||||
enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"],
|
||||
}),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
/** Правила оповещений (UI /alerts). */
|
||||
export const alertRules = sqliteTable("alert_rules", {
|
||||
export const alertRules = pgTable("alert_rules", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
type: text("type", {
|
||||
enum: ["gre-tunnel", "bgp-peer", "bgp-prefix", "gre-client", "server", "rtt", "loss", "traffic"],
|
||||
}).notNull(),
|
||||
/** Сводная подпись (первый target или join); для совместимости и поиска */
|
||||
target: text("target").notNull(),
|
||||
condition: text("condition").notNull(),
|
||||
severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
cooldown: text("cooldown", {
|
||||
enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"],
|
||||
}).notNull().default("5м"),
|
||||
chatId: text("rule_chat_id").notNull().default(""),
|
||||
/** NULL / 0 — выкл. Секунды: уведомление только если условие держится столько времени без «отмены» (восстановление сигнала). */
|
||||
confirmStabilitySec: integer("confirm_stability_sec"),
|
||||
/** Политика recovery-уведомления для правила. */
|
||||
recoveryMode: text("recovery_mode", {
|
||||
enum: ["always", "never", "conditional"],
|
||||
}).notNull().default("always"),
|
||||
/** Доп. задержка подтверждения для recovery при `conditional`; NULL — без доп. ожидания. */
|
||||
recoveryStabilitySec: integer("recovery_stability_sec"),
|
||||
/** NULL — правило вне группы (отдельные отправки по cooldown правила) */
|
||||
groupId: text("group_id"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
groupId: text("group_id").references(() => alertGroups.id, { onDelete: "set null" }),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
/** Несколько объектов на правило; срабатывание по OR (хотя бы один). */
|
||||
export const alertRuleTargets = sqliteTable("alert_rule_targets", {
|
||||
export const alertRuleTargets = pgTable("alert_rule_targets", {
|
||||
id: text("id").primaryKey(),
|
||||
ruleId: text("rule_id").notNull(),
|
||||
ruleId: text("rule_id").notNull().references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
target: text("target").notNull(),
|
||||
sortIndex: integer("sort_index").notNull().default(0),
|
||||
})
|
||||
}, (t) => [
|
||||
index("idx_alert_rule_targets_rule").on(t.ruleId),
|
||||
])
|
||||
|
||||
/** Несколько условий на правило; срабатывание по OR (любое из выбранных). */
|
||||
export const alertRuleConditions = sqliteTable("alert_rule_conditions", {
|
||||
export const alertRuleConditions = pgTable("alert_rule_conditions", {
|
||||
id: text("id").primaryKey(),
|
||||
ruleId: text("rule_id").notNull(),
|
||||
ruleId: text("rule_id").notNull().references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
conditionLine: text("condition_line").notNull(),
|
||||
sortIndex: integer("sort_index").notNull().default(0),
|
||||
})
|
||||
}, (t) => [
|
||||
index("idx_alert_rule_conditions_rule").on(t.ruleId),
|
||||
])
|
||||
|
||||
/** Состояние движка: cooldown / дедуп по ключу rule:id или group:id */
|
||||
export const alertEngineState = sqliteTable("alert_engine_state", {
|
||||
export const alertEngineState = pgTable("alert_engine_state", {
|
||||
scopeKey: text("scope_key").primaryKey(),
|
||||
lastFiredAt: text("last_fired_at").notNull().default(""),
|
||||
lastPayloadHash: text("last_payload_hash"),
|
||||
})
|
||||
|
||||
/** Снимок прошлого тика для live-сигналов (GRE/BGP): переходы «offline» / «восстановился». */
|
||||
export const alertEnginePrevLive = sqliteTable("alert_engine_prev_live", {
|
||||
export const alertEnginePrevLive = pgTable("alert_engine_prev_live", {
|
||||
kind: text("kind", { enum: ["gre", "bgp"] }).primaryKey(),
|
||||
payloadJson: text("payload_json").notNull().default("{}"),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
payloadJson: jsonb("payload_json").notNull().default(sql`'{}'::jsonb`),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
/** Ожидание стабильности срабатывания по правилу (антидребезг). */
|
||||
export const alertEngineConfirmPending = sqliteTable("alert_engine_confirm_pending", {
|
||||
export const alertEngineConfirmPending = pgTable("alert_engine_confirm_pending", {
|
||||
ruleId: text("rule_id").primaryKey(),
|
||||
payloadHash: text("payload_hash").notNull(),
|
||||
sinceAt: text("since_at").notNull(),
|
||||
sinceAt: ts("since_at").notNull(),
|
||||
})
|
||||
|
||||
/** Задел: несколько получателей Telegram (пока UI не подключён) */
|
||||
export const alertDestinations = sqliteTable("alert_destinations", {
|
||||
id: text("id").primaryKey(),
|
||||
kind: text("kind", { enum: ["telegram"] }).notNull().default("telegram"),
|
||||
label: text("label").notNull().default(""),
|
||||
telegramChatId: text("telegram_chat_id").notNull().default(""),
|
||||
messageThreadId: integer("message_thread_id"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
/** Журнал отправок / срабатываний (персист + при необходимости пополняется воркером). */
|
||||
export const alertHistory = sqliteTable("alert_history", {
|
||||
export const alertHistory = pgTable("alert_history", {
|
||||
id: text("id").primaryKey(),
|
||||
ruleId: text("rule_id"),
|
||||
groupId: text("group_id"),
|
||||
ruleName: text("rule_name").notNull(),
|
||||
severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(),
|
||||
message: text("message").notNull(),
|
||||
sentOk: integer("sent_ok", { mode: "boolean" }).notNull().default(true),
|
||||
firedAt: text("fired_at").notNull(),
|
||||
})
|
||||
sentOk: boolean("sent_ok").notNull().default(true),
|
||||
firedAt: ts("fired_at").notNull(),
|
||||
}, (t) => [
|
||||
index("idx_alert_history_fired_at").on(t.firedAt),
|
||||
index("idx_alert_history_rule_id").on(t.ruleId),
|
||||
])
|
||||
|
||||
/** Outbox доставки уведомлений: ретраи и идемпотентность отправки каналов. */
|
||||
export const alertOutbox = sqliteTable("alert_outbox", {
|
||||
export const alertOutbox = pgTable("alert_outbox", {
|
||||
id: text("id").primaryKey(),
|
||||
dedupeKey: text("dedupe_key").notNull(),
|
||||
channel: text("channel", { enum: ["telegram"] }).notNull().default("telegram"),
|
||||
status: text("status", { enum: ["pending", "sent", "failed"] }).notNull().default("pending"),
|
||||
retryCount: integer("retry_count").notNull().default(0),
|
||||
maxRetries: integer("max_retries").notNull().default(3),
|
||||
nextAttemptAt:text("next_attempt_at").notNull(),
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
sentAt: text("sent_at"),
|
||||
nextAttemptAt: ts("next_attempt_at").notNull(),
|
||||
payloadJson: jsonb("payload_json").notNull(),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
sentAt: ts("sent_at"),
|
||||
lastError: text("last_error"),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_alert_outbox_dedupe").on(t.dedupeKey),
|
||||
index("idx_alert_outbox_status_next_attempt").on(t.status, t.nextAttemptAt),
|
||||
])
|
||||
|
||||
export const alertEngineCursor = pgTable("alert_engine_cursor", {
|
||||
id: idSingleton(),
|
||||
lastSourceFinishedAt: ts("last_source_finished_at"),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
/** Cursor движка: watermark последней обработанной точки snapshot-источников. */
|
||||
export const alertEngineCursor = sqliteTable("alert_engine_cursor", {
|
||||
id: integer("id").primaryKey(),
|
||||
lastSourceFinishedAt: text("last_source_finished_at"),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
/** Журнал прогонов планировщика (аудит). */
|
||||
export const schedulerRuns = sqliteTable("scheduler_runs", {
|
||||
export const schedulerRuns = pgTable("scheduler_runs", {
|
||||
id: text("id").primaryKey(),
|
||||
jobKey: text("job_key").notNull(),
|
||||
startedAt: text("started_at").notNull(),
|
||||
finishedAt: text("finished_at").notNull(),
|
||||
startedAt: ts("started_at").notNull(),
|
||||
finishedAt: ts("finished_at").notNull(),
|
||||
status: text("status", { enum: ["ok", "error"] }).notNull(),
|
||||
error: text("error"),
|
||||
durationMs: integer("duration_ms").notNull().default(0),
|
||||
/** JSON: см. `SchedulerRunSnapshot` в types/scheduler-run-snapshot.ts */
|
||||
resultJson: text("result_json"),
|
||||
})
|
||||
resultJson: jsonb("result_json"),
|
||||
}, (t) => [
|
||||
index("idx_scheduler_runs_job_time").on(t.jobKey, t.startedAt),
|
||||
])
|
||||
|
||||
/** Централизованный append-only журнал событий системы. */
|
||||
export const events = sqliteTable("events", {
|
||||
export const events = pgTable("events", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
createdAt: ts("created_at").notNull(),
|
||||
level: text("level", { enum: ["critical", "warning", "info"] }).notNull(),
|
||||
eventType: text("event_type").notNull(),
|
||||
sourceModule: text("source_module").notNull(),
|
||||
@@ -501,14 +576,21 @@ export const events = sqliteTable("events", {
|
||||
message: text("message").notNull(),
|
||||
entityType: text("entity_type"),
|
||||
entityId: text("entity_id"),
|
||||
payloadJson: text("payload_json"),
|
||||
})
|
||||
payloadJson: jsonb("payload_json"),
|
||||
}, (t) => [
|
||||
index("idx_events_created_at").on(t.createdAt),
|
||||
index("idx_events_level_created_at").on(t.level, t.createdAt),
|
||||
index("idx_events_source_created_at").on(t.sourceModule, t.createdAt),
|
||||
index("idx_events_event_type_created_at").on(t.eventType, t.createdAt),
|
||||
])
|
||||
|
||||
export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
|
||||
export const uptimeSpeedTestRuns = pgTable("uptime_speed_test_runs", {
|
||||
id: text("id").primaryKey(),
|
||||
probeId: text("probe_id"),
|
||||
srcServerId: integer("src_server_id").notNull().references(() => servers.id, { onDelete: "cascade" }),
|
||||
dstServerId: integer("dst_server_id").notNull().references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
dstServerId: bigint("dst_server_id", { mode: "number" }).notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
srcInterface: text("src_interface").notNull().default(""),
|
||||
dstInterface: text("dst_interface").notNull().default(""),
|
||||
srcAddress: text("src_address"),
|
||||
@@ -518,35 +600,78 @@ export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
|
||||
protocol: text("protocol", { enum: ["tcp", "udp"] }).notNull().default("tcp"),
|
||||
direction: text("direction", { enum: ["transmit", "receive", "both"] }).notNull().default("both"),
|
||||
durationSec: integer("duration_sec").notNull().default(10),
|
||||
txAvgMbps: real("tx_avg_mbps"),
|
||||
rxAvgMbps: real("rx_avg_mbps"),
|
||||
txAvgMbps: doublePrecision("tx_avg_mbps"),
|
||||
rxAvgMbps: doublePrecision("rx_avg_mbps"),
|
||||
pingRttMs: integer("ping_rtt_ms"),
|
||||
pingLossPct: integer("ping_loss_pct"),
|
||||
pingError: text("ping_error"),
|
||||
status: text("status", { enum: ["done", "error"] }).notNull().default("done"),
|
||||
error: text("error"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("idx_uptime_speed_test_runs_created_at").on(t.createdAt),
|
||||
])
|
||||
|
||||
export const internetPathSettings = sqliteTable("internet_path_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
export const internetPathSettings = pgTable("internet_path_settings", {
|
||||
id: idSingleton(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(300),
|
||||
retentionDays: integer("retention_days").notNull().default(14),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastCollectedAt: ts("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
export const appUsers = pgTable("app_users", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull().default(""),
|
||||
login: text("login").notNull().unique(),
|
||||
email: text("email").notNull().default(""),
|
||||
role: text("role", { enum: ["admin", "operator", "viewer"] }).notNull().default("viewer"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
avatar: text("avatar").notNull().default(""),
|
||||
lastSeen: ts("last_seen"),
|
||||
sectionsJson: jsonb("sections_json").notNull().default(sql`'[]'::jsonb`),
|
||||
serversJson: jsonb("servers_json").notNull().default(sql`'[]'::jsonb`),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
// ── inferred types ─────────────────────────────────────────────────────────────
|
||||
export const userInterfaceBindings = pgTable("user_interface_bindings", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => appUsers.id, { onDelete: "cascade" }),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
interfaceName: text("interface_name").notNull(),
|
||||
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
|
||||
.notNull().default("other"),
|
||||
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||
peerName: text("peer_name").notNull().default(""),
|
||||
comment: text("comment").notNull().default(""),
|
||||
createdAt: ts("created_at").notNull().defaultNow(),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
|
||||
index("idx_user_iface_bind_user").on(t.userId),
|
||||
])
|
||||
|
||||
export const internetPathSnapshots = pgTable("internet_path_snapshots", {
|
||||
id: idIdentity(),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
payloadJson: jsonb("payload_json").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_internet_path_snapshots_sampled").on(t.sampledAt),
|
||||
])
|
||||
|
||||
export const dataMigration = pgTable("data_migration", {
|
||||
id: integer("id").primaryKey(),
|
||||
sqliteImportedAt: ts("sqlite_imported_at"),
|
||||
sqlitePath: text("sqlite_path"),
|
||||
sqliteSha256: text("sqlite_sha256"),
|
||||
reportJson: jsonb("report_json"),
|
||||
})
|
||||
|
||||
export type Server = typeof servers.$inferSelect
|
||||
export type ServerInsert = typeof servers.$inferInsert
|
||||
@@ -555,6 +680,10 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
|
||||
export type FilterRuleRow = typeof filterRules.$inferSelect
|
||||
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
||||
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
||||
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
|
||||
export type FlowBucketRow = typeof flowBuckets.$inferSelect
|
||||
export type FlowIpMetaRow = typeof flowIpMeta.$inferSelect
|
||||
export type FlowAsnMetaRow = typeof flowAsnMeta.$inferSelect
|
||||
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
||||
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
||||
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||
@@ -578,7 +707,8 @@ export type AlertGroupRow = typeof alertGroups.$inferSelect
|
||||
export type AlertRuleRow = typeof alertRules.$inferSelect
|
||||
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
|
||||
export type AlertEngineStateRow = typeof alertEngineState.$inferSelect
|
||||
export type AlertDestinationRow = typeof alertDestinations.$inferSelect
|
||||
export type AlertHistoryRow = typeof alertHistory.$inferSelect
|
||||
export type AlertOutboxRow = typeof alertOutbox.$inferSelect
|
||||
export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect
|
||||
export type AppUserRow = typeof appUsers.$inferSelect
|
||||
export type UserInterfaceBindingRow = typeof userInterfaceBindings.$inferSelect
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { bindSql } from "./sql-bind.js"
|
||||
|
||||
{
|
||||
const q = bindSql("SELECT 1")
|
||||
assert.deepEqual(q, { text: "SELECT 1", values: [] })
|
||||
}
|
||||
|
||||
{
|
||||
const q = bindSql("SELECT * FROM t WHERE a = ? AND b = ?", [1, "x"])
|
||||
assert.equal(q.text, "SELECT * FROM t WHERE a = $1 AND b = $2")
|
||||
assert.deepEqual(q.values, [1, "x"])
|
||||
}
|
||||
|
||||
{
|
||||
const q = bindSql("INSERT INTO t (a, b) VALUES (@a, @b)", { a: 3, b: "y" })
|
||||
assert.equal(q.text, "INSERT INTO t (a, b) VALUES ($1, $2)")
|
||||
assert.deepEqual(q.values, [3, "y"])
|
||||
}
|
||||
|
||||
console.log("sql-bind.test.ts: ok")
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Convert SQLite-style @name / ? placeholders to node-pg $n. */
|
||||
|
||||
export function bindSql(
|
||||
sql: string,
|
||||
params?: unknown[] | Record<string, unknown>,
|
||||
): { text: string; values: unknown[] } {
|
||||
if (params == null) return { text: sql, values: [] }
|
||||
if (Array.isArray(params)) {
|
||||
let i = 0
|
||||
const text = sql.replace(/\?/g, () => {
|
||||
i += 1
|
||||
return `$${i}`
|
||||
})
|
||||
return { text, values: params }
|
||||
}
|
||||
const values: unknown[] = []
|
||||
const text = sql.replace(/@([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name: string) => {
|
||||
values.push(params[name])
|
||||
return `$${values.length}`
|
||||
})
|
||||
return { text, values }
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import Database from "better-sqlite3"
|
||||
import type { Pool } from "pg"
|
||||
import { env } from "../config.js"
|
||||
import { ensurePartitionFor, specForParent } from "./partitions.js"
|
||||
|
||||
export interface ImportReport {
|
||||
sqlitePath: string
|
||||
sqliteSha256: string
|
||||
tables: Record<string, { sqlite: number; copied: number; skipped: number }>
|
||||
rejects: string[]
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
const SNAPSHOT_RETENTION_DAYS = 14
|
||||
|
||||
type ColKind = "ts" | "date" | "bool" | "json" | "json-null" | "bigint-id" | "int" | "text" | "num"
|
||||
|
||||
interface TableCopy {
|
||||
table: string
|
||||
columns: Array<[string, ColKind]>
|
||||
timeCol?: string
|
||||
retentionDays?: number
|
||||
identity?: boolean
|
||||
upsert?: boolean
|
||||
}
|
||||
|
||||
const TABLES: TableCopy[] = [
|
||||
{ table: "servers", identity: true, columns: [
|
||||
["id", "int"], ["name", "text"], ["host", "text"], ["port", "int"], ["username", "text"],
|
||||
["password", "text"], ["use_ssl", "bool"], ["verify_ssl", "bool"], ["type", "text"],
|
||||
["site", "text"], ["country", "text"], ["asn", "text"], ["comment", "text"], ["enabled", "bool"],
|
||||
["lan_subnet", "text"], ["wan_uplinks", "json"], ["mgmt_tunnel_ip", "text"],
|
||||
["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "traffic_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
|
||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||
["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "servers_api_ping_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"],
|
||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||
["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "traffic_flow_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["collector_ip", "text"], ["flow_listen_port", "int"],
|
||||
["wg_listen_port", "int"], ["prefix", "text"], ["public_endpoint", "text"],
|
||||
["host_public_key", "text"], ["host_private_key", "text"], ["hub_server_id", "int"],
|
||||
["retention_hours", "int"], ["top_n", "int"], ["map_service_min_share_pct", "num"],
|
||||
["last_datagram_at", "ts"], ["last_exporter_ip", "text"], ["last_error", "text"],
|
||||
["packets_received", "int"], ["peers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "uptime_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["resources_enabled", "bool"], ["ping_enabled", "bool"],
|
||||
["speed_enabled", "bool"], ["interval_sec", "int"], ["probe_interval_sec", "int"],
|
||||
["speed_interval_sec", "int"], ["retention_days", "int"], ["last_collected_at", "ts"],
|
||||
["last_duration_ms", "int"], ["last_error", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "evobgp_settings", upsert: true, columns: [
|
||||
["id", "int"], ["base_url", "text"], ["api_key", "text"], ["enabled", "bool"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_telegram_settings", upsert: true, columns: [
|
||||
["id", "int"], ["bot_token", "text"], ["chat_id", "text"], ["message_thread_id", "int"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "acme_settings", upsert: true, columns: [
|
||||
["id", "int"], ["directory_url", "text"], ["cloudflare_api_token", "text"],
|
||||
["default_zone_id", "text"], ["account_private_key", "text"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "certificate_renew_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["renew_before_days", "int"],
|
||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "backup_schedule_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["frequency", "text"], ["hour", "int"], ["minute", "int"],
|
||||
["week_day", "int"], ["month_day", "int"], ["keep_count", "int"], ["format", "text"],
|
||||
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
|
||||
["last_error", "text"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "internet_path_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
|
||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||
["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_engine_cursor", upsert: true, columns: [
|
||||
["id", "int"], ["last_source_finished_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "filter_rules", identity: true, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["community", "text"],
|
||||
["community_name", "text"], ["action", "text"], ["gateway", "text"],
|
||||
["gateway_tunnel_id", "text"], ["description", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "recursive_routes", identity: true, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["dst_address", "text"],
|
||||
["gateway", "text"], ["distance", "int"], ["scope", "int"], ["target_scope", "int"],
|
||||
["routing_table", "text"], ["check_gateway", "text"], ["country", "text"], ["comment", "text"],
|
||||
["disabled", "bool"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "server_snapshots", identity: true, timeCol: "polled_at", retentionDays: SNAPSHOT_RETENTION_DAYS, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["polled_at", "ts"], ["status", "text"], ["latency_ms", "num"],
|
||||
["ros_version", "text"], ["board_name", "text"], ["uptime", "text"], ["cpu_load", "int"],
|
||||
["free_memory", "int"], ["total_memory", "int"], ["identity_name", "text"],
|
||||
["raw_interfaces", "json-null"], ["raw_ip_addresses", "json-null"],
|
||||
]},
|
||||
{ table: "traffic_samples", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["interface_name", "text"], ["peer_public_key", "text"],
|
||||
["sampled_at", "ts"], ["rx_bytes", "int"], ["tx_bytes", "int"], ["rx_bps", "int"], ["tx_bps", "int"],
|
||||
["running", "bool"], ["disabled", "bool"],
|
||||
]},
|
||||
{ table: "servers_rest_ping_samples", identity: true, timeCol: "sampled_at", retentionDays: 30, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["sampled_at", "ts"], ["ok", "bool"], ["latency_ms", "int"], ["error", "text"],
|
||||
]},
|
||||
{ table: "flow_buckets", timeCol: "bucket_at", retentionDays: 2, columns: [
|
||||
["server_id", "int"], ["bucket_at", "ts"], ["src", "text"], ["dst", "text"], ["proto", "int"],
|
||||
["src_port", "int"], ["dst_port", "int"], ["bytes", "int"], ["packets", "int"],
|
||||
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "text"],
|
||||
["flow_start_ms", "int"], ["flow_end_ms", "int"],
|
||||
]},
|
||||
{ table: "flow_minute_stats", timeCol: "bucket_at", retentionDays: 3, columns: [
|
||||
["server_id", "int"], ["bucket_at", "ts"], ["bytes", "int"], ["packets", "int"],
|
||||
["unique_src", "int"], ["unique_dst", "int"], ["conversations", "int"],
|
||||
]},
|
||||
{ table: "flow_minute_dims", timeCol: "bucket_at", retentionDays: 3, columns: [
|
||||
["server_id", "int"], ["bucket_at", "ts"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
|
||||
]},
|
||||
{ table: "flow_daily_dims", timeCol: "day", retentionDays: 396, columns: [
|
||||
["server_id", "int"], ["day", "date"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
|
||||
]},
|
||||
{ table: "flow_ip_meta", columns: [
|
||||
["prefix", "text"], ["asn", "int"], ["country", "text"], ["lat", "num"], ["lng", "num"],
|
||||
["holder", "text"], ["ok", "int"], ["fetched_at", "ts"],
|
||||
]},
|
||||
{ table: "flow_asn_meta", columns: [
|
||||
["asn", "int"], ["holder", "text"], ["fetched_at", "ts"],
|
||||
]},
|
||||
{ table: "uptime_probes", columns: [
|
||||
["id", "text"], ["src_server_id", "int"], ["src_interface", "text"], ["name", "text"],
|
||||
["target", "text"], ["probe_filter", "text"], ["enabled", "bool"], ["interval_sec", "int"],
|
||||
["show_on_dashboard", "bool"], ["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "uptime_probe_samples", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["id", "int"], ["probe_id", "text"], ["sampled_at", "ts"], ["rtt_ms", "int"], ["loss_pct", "int"], ["status", "text"],
|
||||
]},
|
||||
{ table: "uptime_resource_samples", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["sampled_at", "ts"], ["status", "text"], ["cpu_load", "int"],
|
||||
["free_memory", "int"], ["total_memory", "int"], ["free_hdd_space", "int"], ["total_hdd_space", "int"],
|
||||
["uptime_seconds", "int"], ["board_name", "text"], ["ros_version", "text"],
|
||||
]},
|
||||
{ table: "uptime_speed_probes", columns: [
|
||||
["id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"], ["src_interface", "text"],
|
||||
["dst_interface", "text"], ["protocol", "text"], ["direction", "text"], ["duration_sec", "int"],
|
||||
["enabled", "bool"], ["last_run_at", "ts"], ["last_tx_avg_mbps", "num"], ["last_rx_avg_mbps", "num"],
|
||||
["last_status", "text"], ["last_error", "text"], ["last_ping_rtt_ms", "int"],
|
||||
["last_ping_loss_pct", "int"], ["last_ping_at", "ts"], ["last_ping_error", "text"],
|
||||
["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "uptime_speed_test_runs", columns: [
|
||||
["id", "text"], ["probe_id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"],
|
||||
["src_interface", "text"], ["dst_interface", "text"], ["src_address", "text"], ["dst_address", "text"],
|
||||
["src_interface_address", "text"], ["dst_interface_address", "text"], ["protocol", "text"],
|
||||
["direction", "text"], ["duration_sec", "int"], ["tx_avg_mbps", "num"], ["rx_avg_mbps", "num"],
|
||||
["ping_rtt_ms", "int"], ["ping_loss_pct", "int"], ["ping_error", "text"], ["status", "text"],
|
||||
["error", "text"], ["created_at", "ts"],
|
||||
]},
|
||||
{ table: "app_users", columns: [
|
||||
["id", "text"], ["name", "text"], ["login", "text"], ["email", "text"], ["role", "text"],
|
||||
["active", "bool"], ["avatar", "text"], ["last_seen", "ts"], ["sections_json", "json"],
|
||||
["servers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "user_interface_bindings", columns: [
|
||||
["id", "text"], ["user_id", "text"], ["server_id", "int"], ["interface_name", "text"],
|
||||
["interface_type", "text"], ["peer_public_key", "text"], ["peer_name", "text"],
|
||||
["comment", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_groups", columns: [
|
||||
["id", "text"], ["name", "text"], ["combine_mode", "text"], ["enabled", "bool"],
|
||||
["cooldown_override", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_rules", columns: [
|
||||
["id", "text"], ["name", "text"], ["type", "text"], ["target", "text"], ["condition", "text"],
|
||||
["severity", "text"], ["enabled", "bool"], ["cooldown", "text"], ["rule_chat_id", "text"],
|
||||
["confirm_stability_sec", "int"], ["recovery_mode", "text"], ["recovery_stability_sec", "int"],
|
||||
["group_id", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_rule_targets", columns: [
|
||||
["id", "text"], ["rule_id", "text"], ["target", "text"], ["sort_index", "int"],
|
||||
]},
|
||||
{ table: "alert_rule_conditions", columns: [
|
||||
["id", "text"], ["rule_id", "text"], ["condition_line", "text"], ["sort_index", "int"],
|
||||
]},
|
||||
{ table: "alert_engine_state", columns: [
|
||||
["scope_key", "text"], ["last_fired_at", "text"], ["last_payload_hash", "text"],
|
||||
]},
|
||||
{ table: "alert_engine_prev_live", columns: [
|
||||
["kind", "text"], ["payload_json", "json"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_engine_confirm_pending", columns: [
|
||||
["rule_id", "text"], ["payload_hash", "text"], ["since_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_history", columns: [
|
||||
["id", "text"], ["rule_id", "text"], ["group_id", "text"], ["rule_name", "text"],
|
||||
["severity", "text"], ["message", "text"], ["sent_ok", "bool"], ["fired_at", "ts"],
|
||||
]},
|
||||
{ table: "alert_outbox", columns: [
|
||||
["id", "text"], ["dedupe_key", "text"], ["channel", "text"], ["status", "text"],
|
||||
["retry_count", "int"], ["max_retries", "int"], ["next_attempt_at", "ts"],
|
||||
["payload_json", "json"], ["created_at", "ts"], ["sent_at", "ts"], ["last_error", "text"],
|
||||
]},
|
||||
{ table: "certificate_issue_jobs", columns: [
|
||||
["id", "text"], ["status", "text"], ["step", "text"], ["source", "text"],
|
||||
["server_id", "bigint-id"], ["cert_name", "text"], ["domain_names", "json"],
|
||||
["key_type", "text"], ["trust_store", "text"], ["requested_at", "ts"],
|
||||
["started_at", "ts"], ["finished_at", "ts"], ["error", "text"],
|
||||
]},
|
||||
{ table: "backup_entries", columns: [
|
||||
["id", "text"], ["server_id", "bigint-id"], ["server_name", "text"], ["filename", "text"],
|
||||
["size_bytes", "int"], ["kind", "text"], ["notes", "text"], ["created_at", "ts"],
|
||||
]},
|
||||
{ table: "scheduler_runs", timeCol: "started_at", retentionDays: 30, columns: [
|
||||
["id", "text"], ["job_key", "text"], ["started_at", "ts"], ["finished_at", "ts"],
|
||||
["status", "text"], ["error", "text"], ["duration_ms", "int"], ["result_json", "json-null"],
|
||||
]},
|
||||
{ table: "events", timeCol: "created_at", retentionDays: 30, columns: [
|
||||
["id", "text"], ["created_at", "ts"], ["level", "text"], ["event_type", "text"],
|
||||
["source_module", "text"], ["title", "text"], ["message", "text"],
|
||||
["entity_type", "text"], ["entity_id", "text"], ["payload_json", "json-null"],
|
||||
]},
|
||||
{ table: "internet_path_snapshots", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["id", "int"], ["sampled_at", "ts"], ["payload_json", "json"],
|
||||
]},
|
||||
]
|
||||
|
||||
function parseTs(value: unknown, strict: boolean, rejects: string[], ctx: string): string | null {
|
||||
if (value == null || value === "") return null
|
||||
const s = String(value)
|
||||
try {
|
||||
if (/^\d{4}-\d{2}-\d{2}T/.test(s)) return new Date(s).toISOString()
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) {
|
||||
return new Date(`${s.replace(" ", "T")}Z`).toISOString()
|
||||
}
|
||||
const d = new Date(s)
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString()
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
const msg = `${ctx}: неразобранный timestamp ${s}`
|
||||
if (strict) throw new Error(msg)
|
||||
rejects.push(msg)
|
||||
return null
|
||||
}
|
||||
|
||||
function parseDate(value: unknown): string | null {
|
||||
if (value == null || value === "") return null
|
||||
return String(value).slice(0, 10)
|
||||
}
|
||||
|
||||
function parseBool(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1" || value === "true"
|
||||
}
|
||||
|
||||
function parseJson(value: unknown, fallback: unknown): unknown {
|
||||
if (value == null || value === "") return fallback
|
||||
if (typeof value !== "string") return value
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function coerce(kind: ColKind, value: unknown, strict: boolean, rejects: string[], ctx: string): unknown {
|
||||
switch (kind) {
|
||||
case "ts":
|
||||
return parseTs(value, strict, rejects, ctx)
|
||||
case "date":
|
||||
return parseDate(value)
|
||||
case "bool":
|
||||
return parseBool(value)
|
||||
case "json":
|
||||
return parseJson(value, [])
|
||||
case "json-null":
|
||||
return value == null || value === "" ? null : parseJson(value, null)
|
||||
case "bigint-id": {
|
||||
const t = String(value ?? "").trim()
|
||||
if (!t) return null
|
||||
const n = Number(t)
|
||||
if (!Number.isFinite(n)) {
|
||||
const msg = `${ctx}: server_id ${t}`
|
||||
if (strict) throw new Error(msg)
|
||||
rejects.push(msg)
|
||||
return null
|
||||
}
|
||||
return n
|
||||
}
|
||||
case "int":
|
||||
if (value == null || value === "") return null
|
||||
return Number(value)
|
||||
case "num":
|
||||
if (value == null || value === "") return null
|
||||
return Number(value)
|
||||
case "text":
|
||||
return value == null ? "" : String(value)
|
||||
default:
|
||||
return value == null ? null : String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function sqliteTableExists(sqlite: Database.Database, name: string): boolean {
|
||||
const row = sqlite.prepare(
|
||||
`SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
||||
).get(name) as { ok?: number } | undefined
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
async function setval(pool: Pool, table: string): Promise<void> {
|
||||
await pool.query(
|
||||
`SELECT setval(
|
||||
pg_get_serial_sequence($1, 'id'),
|
||||
GREATEST(COALESCE((SELECT MAX(id) FROM ${table}), 1), 1),
|
||||
true
|
||||
)`,
|
||||
[table],
|
||||
)
|
||||
}
|
||||
|
||||
async function copyTable(
|
||||
sqlite: Database.Database,
|
||||
pool: Pool,
|
||||
spec: TableCopy,
|
||||
opts: { strict: boolean; fullHistory: boolean; rejects: string[] },
|
||||
): Promise<{ sqlite: number; copied: number; skipped: number }> {
|
||||
if (!sqliteTableExists(sqlite, spec.table)) {
|
||||
return { sqlite: 0, copied: 0, skipped: 0 }
|
||||
}
|
||||
let where = ""
|
||||
if (spec.timeCol && spec.retentionDays && !opts.fullHistory) {
|
||||
const cutoff = new Date(Date.now() - spec.retentionDays * 86400_000).toISOString()
|
||||
where = ` WHERE ${spec.timeCol} >= '${cutoff.replace("T", " ").slice(0, 19)}' OR ${spec.timeCol} >= '${cutoff}'`
|
||||
}
|
||||
const total = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}${where}`).get() as { c: number }).c
|
||||
const part = specForParent(spec.table)
|
||||
const cols = spec.columns.map(([c]) => c)
|
||||
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ")
|
||||
const conflictSql = spec.upsert
|
||||
? `ON CONFLICT (id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c} = EXCLUDED.${c}`).join(", ")}`
|
||||
: `ON CONFLICT DO NOTHING`
|
||||
const insertSql = `INSERT INTO ${spec.table} (${cols.join(", ")}) VALUES (${placeholders}) ${conflictSql}`
|
||||
let copied = 0
|
||||
let skipped = 0
|
||||
const stmt = sqlite.prepare(`SELECT * FROM ${spec.table}${where}`)
|
||||
const batch: unknown[][] = []
|
||||
const flush = async () => {
|
||||
if (batch.length === 0) return
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("BEGIN")
|
||||
for (const values of batch) {
|
||||
await client.query(insertSql, values)
|
||||
copied += 1
|
||||
}
|
||||
await client.query("COMMIT")
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK")
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
batch.length = 0
|
||||
}
|
||||
}
|
||||
for (const row of stmt.iterate() as Iterable<Record<string, unknown>>) {
|
||||
try {
|
||||
if (part && spec.timeCol) {
|
||||
const raw = row[spec.timeCol]
|
||||
const ts = spec.columns.find((c) => c[0] === spec.timeCol)?.[1] === "date"
|
||||
? `${String(raw).slice(0, 10)}T00:00:00Z`
|
||||
: parseTs(raw, false, opts.rejects, spec.table)
|
||||
if (ts) await ensurePartitionFor(pool, spec.table, part.kind, new Date(ts))
|
||||
}
|
||||
const values = spec.columns.map(([col, kind]) =>
|
||||
coerce(kind, row[col], opts.strict, opts.rejects, `${spec.table}.${col}`),
|
||||
)
|
||||
if (spec.table === "certificate_issue_jobs" && values[4] == null) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
batch.push(values)
|
||||
if (batch.length >= 200) await flush()
|
||||
} catch (err) {
|
||||
skipped += 1
|
||||
const msg = `${spec.table}: ${err instanceof Error ? err.message : String(err)}`
|
||||
opts.rejects.push(msg)
|
||||
if (opts.strict) throw err
|
||||
}
|
||||
}
|
||||
await flush()
|
||||
if (spec.identity) {
|
||||
try {
|
||||
await setval(pool, spec.table)
|
||||
} catch {
|
||||
/* partitioned identity sequence name may differ */
|
||||
}
|
||||
}
|
||||
return { sqlite: total, copied, skipped }
|
||||
}
|
||||
|
||||
export function sqliteFileLooksPresent(path: string): boolean {
|
||||
if (!existsSync(path) || path === ":memory:") return false
|
||||
try {
|
||||
const buf = readFileSync(path)
|
||||
return buf.subarray(0, 16).toString("utf8").startsWith("SQLite format 3")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function shouldImportSqlite(pool: Pool, sqlitePath: string): Promise<boolean> {
|
||||
if (!sqliteFileLooksPresent(sqlitePath)) return false
|
||||
const marker = await pool.query<{ sqlite_imported_at: string | null }>(
|
||||
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
|
||||
)
|
||||
if (marker.rows[0]?.sqlite_imported_at) return false
|
||||
const servers = await pool.query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM servers`)
|
||||
if (Number(servers.rows[0]?.c ?? 0) > 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export async function importSqliteToPostgres(
|
||||
pool: Pool,
|
||||
sqlitePath: string,
|
||||
opts?: { dryRun?: boolean; strict?: boolean; fullHistory?: boolean },
|
||||
): Promise<ImportReport> {
|
||||
const started = Date.now()
|
||||
const rejects: string[] = []
|
||||
const sqlite = new Database(sqlitePath, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
sqlite.pragma("journal_mode = WAL")
|
||||
} catch {
|
||||
/* readonly */
|
||||
}
|
||||
const sha = createHash("sha256").update(readFileSync(sqlitePath)).digest("hex")
|
||||
const report: ImportReport = {
|
||||
sqlitePath,
|
||||
sqliteSha256: sha,
|
||||
tables: {},
|
||||
rejects,
|
||||
durationMs: 0,
|
||||
}
|
||||
const fullHistory = opts?.fullHistory ?? env.sqliteImportFullHistory
|
||||
const strict = opts?.strict ?? true
|
||||
if (opts?.dryRun) {
|
||||
for (const spec of TABLES) {
|
||||
if (!sqliteTableExists(sqlite, spec.table)) {
|
||||
report.tables[spec.table] = { sqlite: 0, copied: 0, skipped: 0 }
|
||||
continue
|
||||
}
|
||||
const c = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}`).get() as { c: number }).c
|
||||
report.tables[spec.table] = { sqlite: c, copied: 0, skipped: 0 }
|
||||
}
|
||||
sqlite.close()
|
||||
report.durationMs = Date.now() - started
|
||||
return report
|
||||
}
|
||||
for (const spec of TABLES) {
|
||||
report.tables[spec.table] = await copyTable(sqlite, pool, spec, { strict, fullHistory, rejects })
|
||||
}
|
||||
sqlite.close()
|
||||
await pool.query(
|
||||
`UPDATE data_migration
|
||||
SET sqlite_imported_at = now(), sqlite_path = $1, sqlite_sha256 = $2, report_json = $3::jsonb
|
||||
WHERE id = 1`,
|
||||
[sqlitePath, sha, JSON.stringify(report.tables)],
|
||||
)
|
||||
report.durationMs = Date.now() - started
|
||||
if (strict && rejects.length > 0) {
|
||||
throw new Error(`SQLite import strict: ${rejects.length} rejects\n${rejects.slice(0, 20).join("\n")}`)
|
||||
}
|
||||
return report
|
||||
}
|
||||
+57
-3
@@ -1,7 +1,10 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify"
|
||||
import Fastify, { type FastifyError, type FastifyInstance } from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
||||
import { monitorEventLoopDelay } from "node:perf_hooks"
|
||||
import { env } from "./config.js"
|
||||
import { initDatabase } from "./db/bootstrap.js"
|
||||
import { closePool } from "./db/index.js"
|
||||
import authPlugin, { requireAuth } from "./plugins/auth.js"
|
||||
import serversRoutes from "./routes/servers.js"
|
||||
import bgpRoutes from "./routes/bgp.js"
|
||||
@@ -10,6 +13,7 @@ import execRoutes from "./routes/exec.js"
|
||||
import filtersRoutes from "./routes/filters.js"
|
||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||
import trafficRoutes from "./routes/traffic.js"
|
||||
import trafficFlowRoutes from "./routes/traffic-flow.js"
|
||||
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
||||
import uptimeRoutes from "./routes/uptime.js"
|
||||
import networkRoutes from "./routes/network.js"
|
||||
@@ -24,7 +28,13 @@ import certificatesRoutes from "./routes/certificates.js"
|
||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||
import eventsRoutes from "./routes/events.js"
|
||||
import wireguardRoutes from "./routes/wireguard.js"
|
||||
import firewallRoutes from "./routes/firewall.js"
|
||||
import usersRoutes from "./routes/users.js"
|
||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
||||
|
||||
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
|
||||
eventLoopDelay.enable()
|
||||
|
||||
export async function buildApp(opts?: {
|
||||
logger?: boolean
|
||||
@@ -33,7 +43,7 @@ export async function buildApp(opts?: {
|
||||
const usePrettyLogger =
|
||||
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
||||
const app = Fastify({
|
||||
bodyLimit: 512 * 1024 * 1024,
|
||||
bodyLimit: 2 * 1024 * 1024,
|
||||
requestTimeout: 10 * 60 * 1000,
|
||||
logger:
|
||||
opts?.logger === false
|
||||
@@ -55,6 +65,18 @@ export async function buildApp(opts?: {
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
|
||||
app.setErrorHandler((error: FastifyError, request, reply) => {
|
||||
const status = typeof error.statusCode === "number" && error.statusCode >= 400
|
||||
? error.statusCode
|
||||
: 500
|
||||
if (status >= 500) {
|
||||
request.log.error(error)
|
||||
return reply.status(status).send({ error: "Внутренняя ошибка сервера" })
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Ошибка запроса"
|
||||
return reply.status(status).send({ error: message })
|
||||
})
|
||||
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
@@ -66,6 +88,8 @@ export async function buildApp(opts?: {
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_VERSION ?? "dev",
|
||||
eventLoopDelayMs: Math.round(eventLoopDelay.mean / 1e6),
|
||||
flowWorker: getFlowWorkerHealth(),
|
||||
}))
|
||||
|
||||
app.get("/api/auth/config", async () => ({
|
||||
@@ -91,6 +115,7 @@ export async function buildApp(opts?: {
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
await app.register(trafficRoutes, { prefix: "/api" })
|
||||
await app.register(trafficFlowRoutes, { prefix: "/api" })
|
||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||
await app.register(networkRoutes, { prefix: "/api" })
|
||||
@@ -105,11 +130,15 @@ export async function buildApp(opts?: {
|
||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||
await app.register(eventsRoutes, { prefix: "/api" })
|
||||
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||
await app.register(firewallRoutes, { prefix: "/api" })
|
||||
await app.register(usersRoutes, { prefix: "/api" })
|
||||
|
||||
if (opts?.startScheduler !== false) {
|
||||
refreshScheduler()
|
||||
await refreshScheduler()
|
||||
await startTrafficFlowListener()
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
stopTrafficFlowListener()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,7 +151,32 @@ const isMain =
|
||||
|
||||
if (isMain) {
|
||||
try {
|
||||
await initDatabase()
|
||||
const app = await buildApp()
|
||||
let shuttingDown = false
|
||||
const shutdown = async (code: number) => {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
try {
|
||||
stopTrafficFlowListener()
|
||||
await app.close()
|
||||
await closePool()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
process.exit(code)
|
||||
}
|
||||
}
|
||||
process.on("SIGTERM", () => { void shutdown(0) })
|
||||
process.on("SIGINT", () => { void shutdown(0) })
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error(err)
|
||||
void shutdown(1)
|
||||
})
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error(reason)
|
||||
void shutdown(1)
|
||||
})
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||
console.log(
|
||||
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
|
||||
|
||||
@@ -17,6 +17,14 @@ assert.equal(
|
||||
permissionForRequest("GET", "/api/system/database/backup"),
|
||||
"mm:settings:admin",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/traffic/servers/1/live"),
|
||||
"mm:traffic:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/traffic/flow/purge"),
|
||||
"mm:traffic:write",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/unknown-thing"),
|
||||
"mm:dashboard:read",
|
||||
@@ -29,5 +37,20 @@ assert.equal(
|
||||
permissionForRequest("POST", "/api/wireguard/interfaces"),
|
||||
"mm:network:write",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/firewall/all"),
|
||||
"mm:network:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("GET", "/api/users"),
|
||||
"mm:users:read",
|
||||
)
|
||||
assert.equal(
|
||||
permissionForRequest("POST", "/api/users"),
|
||||
"mm:users:write",
|
||||
)
|
||||
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
|
||||
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
|
||||
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
|
||||
|
||||
console.log("permissions.test.ts: ok")
|
||||
|
||||
@@ -17,6 +17,9 @@ export function hasPermission(
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) return true
|
||||
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
|
||||
return true
|
||||
}
|
||||
const parts = required.split(":")
|
||||
if (parts.length !== 3) return false
|
||||
const [app, section, action] = parts
|
||||
@@ -49,8 +52,17 @@ const RULES: Rule[] = [
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
|
||||
match: (p) => p.startsWith("/api/users"),
|
||||
permission: "mm:users:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/users"),
|
||||
permission: "mm:users:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
|
||||
permission: "mm:dashboard:read",
|
||||
},
|
||||
{
|
||||
@@ -142,7 +154,8 @@ const RULES: Rule[] = [
|
||||
p.startsWith("/api/probes") ||
|
||||
p.startsWith("/api/internet-path") ||
|
||||
p.startsWith("/api/exec") ||
|
||||
p.startsWith("/api/wireguard"),
|
||||
p.startsWith("/api/wireguard") ||
|
||||
p.startsWith("/api/firewall"),
|
||||
permission: "mm:network:read",
|
||||
},
|
||||
{
|
||||
@@ -154,7 +167,8 @@ const RULES: Rule[] = [
|
||||
p.startsWith("/api/probes") ||
|
||||
p.startsWith("/api/internet-path") ||
|
||||
p.startsWith("/api/exec") ||
|
||||
p.startsWith("/api/wireguard"),
|
||||
p.startsWith("/api/wireguard") ||
|
||||
p.startsWith("/api/firewall"),
|
||||
permission: "mm:network:write",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -24,18 +24,7 @@ export type ListEventsParams = {
|
||||
to?: string
|
||||
}
|
||||
|
||||
function parsePayload(payloadJson: string | null): Record<string, unknown> {
|
||||
if (!payloadJson) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(payloadJson) as unknown
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// keep backwards compatibility with malformed legacy payloads
|
||||
}
|
||||
return {}
|
||||
}
|
||||
import { parseJsonObject } from "../../../db/json.js"
|
||||
|
||||
function mapEventRow(row: typeof events.$inferSelect): EventItem {
|
||||
return {
|
||||
@@ -48,13 +37,13 @@ function mapEventRow(row: typeof events.$inferSelect): EventItem {
|
||||
message: row.message,
|
||||
entityType: row.entityType ?? null,
|
||||
entityId: row.entityId ?? null,
|
||||
payload: parsePayload(row.payloadJson ?? null),
|
||||
payload: parseJsonObject(row.payloadJson),
|
||||
}
|
||||
}
|
||||
|
||||
export function insertEventsBatch(items: EventInsertInput[]) {
|
||||
export async function insertEventsBatch(items: EventInsertInput[]) {
|
||||
if (items.length === 0) return
|
||||
db.insert(events)
|
||||
await db.insert(events)
|
||||
.values(
|
||||
items.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -66,25 +55,23 @@ export function insertEventsBatch(items: EventInsertInput[]) {
|
||||
message: item.message,
|
||||
entityType: item.entityType ?? null,
|
||||
entityId: item.entityId ?? null,
|
||||
payloadJson: item.payload ? JSON.stringify(item.payload) : null,
|
||||
payloadJson: item.payload ?? null,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
export function listEvents(params: ListEventsParams): EventItem[] {
|
||||
export async function listEvents(params: ListEventsParams): Promise<EventItem[]> {
|
||||
const where = and(
|
||||
params.level ? eq(events.level, params.level) : undefined,
|
||||
params.sourceModule ? eq(events.sourceModule, params.sourceModule) : undefined,
|
||||
params.from ? gte(events.createdAt, params.from) : undefined,
|
||||
params.to ? lte(events.createdAt, params.to) : undefined,
|
||||
)
|
||||
const rows = db
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(where)
|
||||
.orderBy(desc(events.createdAt))
|
||||
.limit(params.limit)
|
||||
.all()
|
||||
return rows.map(mapEventRow)
|
||||
}
|
||||
|
||||
@@ -23,17 +23,17 @@ function normalizeEventInput(input: AppendEventBody): EventInsertInput {
|
||||
}
|
||||
}
|
||||
|
||||
export function appendEvent(input: AppendEventBody) {
|
||||
export async function appendEvent(input: AppendEventBody) {
|
||||
const parsed = appendEventSchema.parse(input)
|
||||
insertEventsBatch([normalizeEventInput(parsed)])
|
||||
await insertEventsBatch([normalizeEventInput(parsed)])
|
||||
}
|
||||
|
||||
export function appendEvents(inputs: AppendEventBody[]) {
|
||||
export async function appendEvents(inputs: AppendEventBody[]) {
|
||||
const rows = inputs.map((entry) => normalizeEventInput(appendEventSchema.parse(entry)))
|
||||
insertEventsBatch(rows)
|
||||
await insertEventsBatch(rows)
|
||||
}
|
||||
|
||||
export function readEvents(query: Partial<ListEventsQuery>): EventItem[] {
|
||||
export async function readEvents(query: Partial<ListEventsQuery>): Promise<EventItem[]> {
|
||||
const parsed = listEventsQuerySchema.parse(query)
|
||||
return listEvents(parsed)
|
||||
return await listEvents(parsed)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { parseJsonArray } from "../../../db/json.js"
|
||||
import type { ServerRead, WanUplinkRead } from "../../../types/server.js"
|
||||
import type { ServerRow, SnapshotRow } from "../repository/servers-repository.js"
|
||||
|
||||
function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] {
|
||||
if (raw == null || raw === "") return []
|
||||
try {
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(data)) return []
|
||||
const out: WanUplinkRead[] = []
|
||||
for (const row of data) {
|
||||
if (typeof row !== "object" || row === null) continue
|
||||
const r = row as Record<string, unknown>
|
||||
out.push({
|
||||
id: typeof r.id === "string" ? r.id : "",
|
||||
name: typeof r.name === "string" ? r.name : "",
|
||||
isp: typeof r.isp === "string" ? r.isp : "",
|
||||
iface: typeof r.iface === "string" ? r.iface : "",
|
||||
ip: typeof r.ip === "string" ? r.ip : "",
|
||||
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
|
||||
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
|
||||
})
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return []
|
||||
function parseWanUplinksJson(raw: unknown): WanUplinkRead[] {
|
||||
const data = parseJsonArray(raw)
|
||||
const out: WanUplinkRead[] = []
|
||||
for (const row of data) {
|
||||
if (typeof row !== "object" || row === null) continue
|
||||
const r = row as Record<string, unknown>
|
||||
out.push({
|
||||
id: typeof r.id === "string" ? r.id : "",
|
||||
name: typeof r.name === "string" ? r.name : "",
|
||||
isp: typeof r.isp === "string" ? r.isp : "",
|
||||
iface: typeof r.iface === "string" ? r.iface : "",
|
||||
ip: typeof r.ip === "string" ? r.ip : "",
|
||||
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
|
||||
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
|
||||
|
||||
@@ -1,53 +1,57 @@
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { serverSnapshots, servers } from "../../../db/schema.js"
|
||||
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||
|
||||
export type ServerRow = typeof servers.$inferSelect
|
||||
export type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||
|
||||
export function listServerRows(): ServerRow[] {
|
||||
return db.select().from(servers).all()
|
||||
export async function listServerRows(): Promise<ServerRow[]> {
|
||||
return await db.select().from(servers)
|
||||
}
|
||||
|
||||
export function getServerRowById(id: number): ServerRow | undefined {
|
||||
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
|
||||
export async function getServerRowById(id: number): Promise<ServerRow | undefined> {
|
||||
const rows = await db.select().from(servers).where(eq(servers.id, id)).limit(1)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export function createServerRow(
|
||||
export async function createServerRow(
|
||||
values: Omit<typeof servers.$inferInsert, "id">,
|
||||
): ServerRow {
|
||||
const [inserted] = db.insert(servers).values(values).returning().all()
|
||||
): Promise<ServerRow> {
|
||||
const [inserted] = await db.insert(servers).values(values).returning()
|
||||
invalidateFlowCatalogCache()
|
||||
return inserted
|
||||
}
|
||||
|
||||
export function updateServerRowById(
|
||||
export async function updateServerRowById(
|
||||
id: number,
|
||||
values: Partial<ServerRow>,
|
||||
): ServerRow {
|
||||
const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all()
|
||||
): Promise<ServerRow> {
|
||||
const [updated] = await db.update(servers).set(values).where(eq(servers.id, id)).returning()
|
||||
invalidateFlowCatalogCache()
|
||||
return updated
|
||||
}
|
||||
|
||||
export function deleteServerRowById(id: number): void {
|
||||
db.delete(servers).where(eq(servers.id, id)).run()
|
||||
export async function deleteServerRowById(id: number): Promise<void> {
|
||||
await db.delete(servers).where(eq(servers.id, id))
|
||||
invalidateFlowCatalogCache()
|
||||
}
|
||||
|
||||
export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] {
|
||||
export async function listSnapshotsByServerId(serverId: number, limit: number): Promise<SnapshotRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(serverSnapshots)
|
||||
.where(eq(serverSnapshots.serverId, serverId))
|
||||
.orderBy(desc(serverSnapshots.polledAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
}
|
||||
|
||||
export function getLatestSnapshot(serverId: number): SnapshotRow | undefined {
|
||||
return db
|
||||
export async function getLatestSnapshot(serverId: number): Promise<SnapshotRow | undefined> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(serverSnapshots)
|
||||
.where(eq(serverSnapshots.serverId, serverId))
|
||||
.orderBy(desc(serverSnapshots.polledAt))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
@@ -11,45 +11,46 @@ import {
|
||||
import { toServerRead } from "../mapper/servers-mapper.js"
|
||||
import type { ServerCreate, ServerRead, ServerUpdate, SnapshotRead } from "../../../types/server.js"
|
||||
|
||||
export function listServersRead(): ServerRead[] {
|
||||
return listServerRows().map((server) => toServerRead(server, getLatestSnapshot(server.id)))
|
||||
export async function listServersRead(): Promise<ServerRead[]> {
|
||||
const rows = await listServerRows()
|
||||
return Promise.all(rows.map(async (server) => toServerRead(server, await getLatestSnapshot(server.id))))
|
||||
}
|
||||
|
||||
export function getServerReadById(id: number): ServerRead | undefined {
|
||||
const row = getServerRowById(id)
|
||||
export async function getServerReadById(id: number): Promise<ServerRead | undefined> {
|
||||
const row = await getServerRowById(id)
|
||||
if (!row) return undefined
|
||||
return toServerRead(row, getLatestSnapshot(row.id))
|
||||
return toServerRead(row, await getLatestSnapshot(row.id))
|
||||
}
|
||||
|
||||
export function createServer(input: ServerCreate): ServerRead {
|
||||
export async function createServer(input: ServerCreate): Promise<ServerRead> {
|
||||
const now = new Date().toISOString()
|
||||
const { wanUplinks, ...rest } = input
|
||||
const inserted = createServerRow({
|
||||
const inserted = await createServerRow({
|
||||
...rest,
|
||||
wanUplinks: JSON.stringify(wanUplinks ?? []),
|
||||
wanUplinks: wanUplinks ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return toServerRead(inserted, undefined)
|
||||
}
|
||||
|
||||
export function updateServer(id: number, input: ServerUpdate): ServerRead {
|
||||
export async function updateServer(id: number, input: ServerUpdate): Promise<ServerRead> {
|
||||
const { wanUplinks, ...rest } = input
|
||||
const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() }
|
||||
for (const [k, v] of Object.entries(rest)) {
|
||||
if (v !== undefined) setPayload[k] = v
|
||||
}
|
||||
if (wanUplinks !== undefined) {
|
||||
setPayload.wanUplinks = JSON.stringify(wanUplinks)
|
||||
setPayload.wanUplinks = wanUplinks
|
||||
}
|
||||
const updated = updateServerRowById(id, setPayload as never)
|
||||
return toServerRead(updated, getLatestSnapshot(updated.id))
|
||||
const updated = await updateServerRowById(id, setPayload as never)
|
||||
return toServerRead(updated, await getLatestSnapshot(updated.id))
|
||||
}
|
||||
|
||||
export function deleteServer(id: number): void {
|
||||
deleteServerRowById(id)
|
||||
export async function deleteServer(id: number): Promise<void> {
|
||||
await deleteServerRowById(id)
|
||||
}
|
||||
|
||||
export function listServerSnapshots(id: number, limit: number): SnapshotRead[] {
|
||||
return listSnapshotsByServerId(id, limit).map(toSnapshotRead)
|
||||
export async function listServerSnapshots(id: number, limit: number): Promise<SnapshotRead[]> {
|
||||
return (await listSnapshotsByServerId(id, limit)).map(toSnapshotRead)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
|
||||
import { withPgOrSkip } from "../../test/pg.js"
|
||||
import { dbQuery } from "../../db/index.js"
|
||||
|
||||
assert.throws(
|
||||
() => normalizeBindingPeer("wg", ""),
|
||||
(err: unknown) => err instanceof PeerBindError && err.status === 400,
|
||||
"WG без ключа — 400",
|
||||
)
|
||||
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
|
||||
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
console.log("users bindings unique+cascade tests skip")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await dbQuery(`
|
||||
INSERT INTO servers (id, name, host) VALUES (91001, 'jh-bind-test', '10.0.0.1')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`)
|
||||
await dbQuery(`
|
||||
INSERT INTO app_users (id, name, login)
|
||||
VALUES ('u-bind-1', 'A', 'a.bind.test'), ('u-bind-2', 'B', 'b.bind.test')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`)
|
||||
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = 91001`)
|
||||
await dbQuery(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
||||
VALUES ('b-bind-1', 'u-bind-1', 91001, 'gre-office', 'gre')
|
||||
`)
|
||||
|
||||
let uniqueIface = false
|
||||
try {
|
||||
await dbQuery(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
||||
VALUES ('b-bind-2', 'u-bind-2', 91001, 'gre-office', 'gre')
|
||||
`)
|
||||
} catch {
|
||||
uniqueIface = true
|
||||
}
|
||||
assert.equal(uniqueIface, true, "один интерфейс на сервере — один пользователь")
|
||||
|
||||
await dbQuery(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||
VALUES ('wg-bind-1', 'u-bind-1', 91001, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
|
||||
`)
|
||||
await dbQuery(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||
VALUES ('wg-bind-2', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
|
||||
`)
|
||||
|
||||
let uniquePeer = false
|
||||
try {
|
||||
await dbQuery(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
|
||||
VALUES ('wg-bind-3', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-aaa')
|
||||
`)
|
||||
} catch {
|
||||
uniquePeer = true
|
||||
}
|
||||
assert.equal(uniquePeer, true, "один пир — один пользователь")
|
||||
|
||||
await dbQuery(`DELETE FROM app_users WHERE id = 'u-bind-1'`)
|
||||
const leftover = await dbQuery<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM user_interface_bindings WHERE server_id = 91001`,
|
||||
)
|
||||
assert.equal(leftover.rows[0]?.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
|
||||
|
||||
await dbQuery(`DELETE FROM app_users WHERE id IN ('u-bind-1', 'u-bind-2')`)
|
||||
await dbQuery(`DELETE FROM servers WHERE id = 91001`)
|
||||
|
||||
console.log("users bindings unique+cascade tests ok")
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from "./iface-type.js"
|
||||
|
||||
assert.equal(mapRosInterfaceType("ether"), "ether")
|
||||
assert.equal(mapRosInterfaceType("ethernet"), "ether")
|
||||
assert.equal(mapRosInterfaceType("GRE"), "gre")
|
||||
assert.equal(mapRosInterfaceType("gre-tunnel"), "gre")
|
||||
assert.equal(mapRosInterfaceType("gre6-tunnel"), "gre")
|
||||
assert.equal(mapRosInterfaceType("wg"), "wg")
|
||||
assert.equal(mapRosInterfaceType("wireguard"), "wg")
|
||||
assert.equal(mapRosInterfaceType("vlan"), "other")
|
||||
assert.equal(mapRosInterfaceType(""), "other")
|
||||
assert.equal(mapRosInterfaceType("", "gre-tunnel1"), "gre")
|
||||
assert.equal(mapRosInterfaceType("", "MSK-DC"), "other")
|
||||
assert.equal(mapRosInterfaceType("gre-tunnel", "MSK-DC"), "gre")
|
||||
assert.equal(mapRosInterfaceType("", "wg-msk-spb"), "wg")
|
||||
assert.equal(mapRosInterfaceType("", "ether1"), "ether")
|
||||
|
||||
const parsed = parseRawInterfaces(JSON.stringify([
|
||||
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
|
||||
{ name: "gre-office", type: "gre-tunnel", running: "false", disabled: "false" },
|
||||
{ name: "wg-msk", type: "wg", running: true, disabled: false },
|
||||
{ name: "MSK-DC", type: "gre-tunnel", running: true, disabled: false },
|
||||
{ name: "", type: "ether" },
|
||||
]))
|
||||
assert.equal(parsed.length, 4)
|
||||
assert.equal(parsed[0]?.type, "ether")
|
||||
assert.equal(parsed[0]?.running, true)
|
||||
assert.equal(parsed[1]?.type, "gre")
|
||||
assert.equal(parsed[1]?.running, false)
|
||||
assert.equal(parsed[2]?.type, "wg")
|
||||
assert.equal(parsed[3]?.type, "gre")
|
||||
|
||||
assert.equal(parseRawInterfaces("not-json").length, 0)
|
||||
assert.equal(parseRawInterfaces(null).length, 0)
|
||||
|
||||
assert.equal(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE", message: "UNIQUE" }), true)
|
||||
assert.equal(isUniqueConstraintError({ message: "UNIQUE constraint failed: t.c" }), true)
|
||||
assert.equal(isUniqueConstraintError({ message: "other" }), false)
|
||||
|
||||
console.log("users iface-type tests ok")
|
||||
@@ -0,0 +1,60 @@
|
||||
export type InterfaceType = "ether" | "gre" | "wg" | "other"
|
||||
|
||||
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
|
||||
const t = String(raw ?? "").trim().toLowerCase()
|
||||
if (t === "ether" || t === "ethernet" || t.startsWith("ether")) return "ether"
|
||||
// RouterOS /interface type for GRE is "gre-tunnel" (also gre, gre6, gre6-tunnel)
|
||||
if (t === "gre" || t.startsWith("gre-") || t.startsWith("gre6")) return "gre"
|
||||
if (t === "wg" || t === "wireguard") return "wg"
|
||||
|
||||
const n = String(name ?? "").trim().toLowerCase()
|
||||
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
|
||||
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
|
||||
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
|
||||
return "other"
|
||||
}
|
||||
|
||||
export interface ParsedRosIface {
|
||||
name: string
|
||||
type: InterfaceType
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function asBool(raw: unknown): boolean {
|
||||
if (typeof raw === "boolean") return raw
|
||||
const s = String(raw ?? "").trim().toLowerCase()
|
||||
return s === "true" || s === "yes" || s === "1"
|
||||
}
|
||||
|
||||
export function parseRawInterfaces(json: unknown): ParsedRosIface[] {
|
||||
if (json == null || json === "") return []
|
||||
try {
|
||||
const parsed = typeof json === "string" ? JSON.parse(json) as unknown : json
|
||||
const arr = Array.isArray(parsed) ? parsed : []
|
||||
const out: ParsedRosIface[] = []
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const rec = item as Record<string, unknown>
|
||||
const name = String(rec.name ?? "").trim()
|
||||
if (!name) continue
|
||||
out.push({
|
||||
name,
|
||||
type: mapRosInterfaceType(String(rec.type ?? ""), name),
|
||||
running: asBool(rec.running),
|
||||
disabled: asBool(rec.disabled),
|
||||
})
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function isUniqueConstraintError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false
|
||||
const rec = err as { code?: unknown; message?: unknown }
|
||||
const code = String(rec.code ?? "")
|
||||
const msg = String(rec.message ?? "")
|
||||
return code === "23505" || code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { InterfaceType } from "./iface-type.js"
|
||||
|
||||
export class PeerBindError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "PeerBindError"
|
||||
}
|
||||
}
|
||||
|
||||
export function truncPeerKey(key: string): string {
|
||||
const k = key.trim()
|
||||
if (k.length <= 20) return k
|
||||
return `${k.slice(0, 8)}…${k.slice(-8)}`
|
||||
}
|
||||
|
||||
export function peerDisplayName(opts: {
|
||||
publicKey: string
|
||||
name?: string | null
|
||||
comment?: string | null
|
||||
}): string {
|
||||
const name = (opts.name ?? "").trim()
|
||||
if (name) return name
|
||||
const comment = (opts.comment ?? "").trim()
|
||||
if (comment) return comment
|
||||
return truncPeerKey(opts.publicKey)
|
||||
}
|
||||
|
||||
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
|
||||
export function normalizeBindingPeer(
|
||||
type: InterfaceType,
|
||||
peerPublicKey: string | undefined,
|
||||
): string {
|
||||
const key = (peerPublicKey ?? "").trim()
|
||||
if (type === "wg") {
|
||||
if (!key) {
|
||||
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
|
||||
}
|
||||
return key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { and, count, eq } from "drizzle-orm"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
|
||||
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||
|
||||
export type AppUserRow = typeof appUsers.$inferSelect
|
||||
export type BindingRow = typeof userInterfaceBindings.$inferSelect
|
||||
|
||||
export async function listUserRows(): Promise<AppUserRow[]> {
|
||||
return await db.select().from(appUsers)
|
||||
}
|
||||
|
||||
export async function getUserRowById(id: string): Promise<AppUserRow | undefined> {
|
||||
const rows = await db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export async function getUserRowByLogin(login: string): Promise<AppUserRow | undefined> {
|
||||
const rows = await db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export async function createUserRow(values: typeof appUsers.$inferInsert): Promise<AppUserRow> {
|
||||
const [inserted] = await db.insert(appUsers).values(values).returning()
|
||||
invalidateFlowCatalogCache()
|
||||
return inserted
|
||||
}
|
||||
|
||||
export async function updateUserRowById(
|
||||
id: string,
|
||||
values: Partial<AppUserRow>,
|
||||
): Promise<AppUserRow> {
|
||||
const [updated] = await db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning()
|
||||
invalidateFlowCatalogCache()
|
||||
return updated
|
||||
}
|
||||
|
||||
export async function deleteUserRowById(id: string): Promise<void> {
|
||||
await db.delete(appUsers).where(eq(appUsers.id, id))
|
||||
invalidateFlowCatalogCache()
|
||||
}
|
||||
|
||||
export async function listBindingRows(): Promise<BindingRow[]> {
|
||||
return await db.select().from(userInterfaceBindings)
|
||||
}
|
||||
|
||||
export async function listBindingRowsByUser(userId: string): Promise<BindingRow[]> {
|
||||
return await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId))
|
||||
}
|
||||
|
||||
export async function getBindingRowById(id: string): Promise<BindingRow | undefined> {
|
||||
const rows = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export async function getBindingByServerIfacePeer(
|
||||
serverId: number,
|
||||
interfaceName: string,
|
||||
peerPublicKey = "",
|
||||
): Promise<BindingRow | undefined> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(userInterfaceBindings)
|
||||
.where(and(
|
||||
eq(userInterfaceBindings.serverId, serverId),
|
||||
eq(userInterfaceBindings.interfaceName, interfaceName),
|
||||
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
|
||||
))
|
||||
.limit(1)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export async function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): Promise<BindingRow> {
|
||||
const [inserted] = await db.insert(userInterfaceBindings).values(values).returning()
|
||||
invalidateFlowCatalogCache()
|
||||
return inserted
|
||||
}
|
||||
|
||||
export async function deleteBindingRowById(id: string): Promise<void> {
|
||||
await db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id))
|
||||
invalidateFlowCatalogCache()
|
||||
}
|
||||
|
||||
export async function countUserRows(): Promise<number> {
|
||||
const rows = await db.select({ n: count() }).from(appUsers)
|
||||
return rows[0]?.n ?? 0
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import type {
|
||||
AppUserCreate,
|
||||
AppUserRead,
|
||||
AppUserUpdate,
|
||||
CatalogInterface,
|
||||
InterfaceType,
|
||||
SectionPerm,
|
||||
ServerPerm,
|
||||
UserBinding,
|
||||
UserBindingCreate,
|
||||
} from "@mmapp/contracts/users"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { parseJsonArray } from "../../../db/json.js"
|
||||
import { servers, trafficSamples } from "../../../db/schema.js"
|
||||
import {
|
||||
createBindingRow,
|
||||
createUserRow,
|
||||
deleteBindingRowById,
|
||||
deleteUserRowById,
|
||||
getBindingByServerIfacePeer,
|
||||
getBindingRowById,
|
||||
getUserRowById,
|
||||
getUserRowByLogin,
|
||||
listBindingRows,
|
||||
listBindingRowsByUser,
|
||||
listUserRows,
|
||||
updateUserRowById,
|
||||
type AppUserRow,
|
||||
type BindingRow,
|
||||
} from "../repository/users-repository.js"
|
||||
import { getLatestSnapshot } from "../../servers/repository/servers-repository.js"
|
||||
import {
|
||||
isUniqueConstraintError,
|
||||
mapRosInterfaceType,
|
||||
parseRawInterfaces,
|
||||
} from "../iface-type.js"
|
||||
import {
|
||||
normalizeBindingPeer,
|
||||
PeerBindError,
|
||||
peerDisplayName,
|
||||
} from "../peer-bind.js"
|
||||
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
|
||||
|
||||
export class UsersServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "UsersServiceError"
|
||||
}
|
||||
}
|
||||
|
||||
function asPermArray<T>(raw: unknown, fallback: T[]): T[] {
|
||||
const arr = parseJsonArray(raw)
|
||||
return arr.length ? arr as T[] : fallback
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
return parts.map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
|
||||
}
|
||||
|
||||
async function serverMeta(serverId: number): Promise<{ name: string; site: string; country: string }> {
|
||||
const rows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
|
||||
const row = rows[0]
|
||||
return {
|
||||
name: row?.name || row?.host || String(serverId),
|
||||
site: row?.site || "—",
|
||||
country: row?.country || "UN",
|
||||
}
|
||||
}
|
||||
|
||||
async function toBindingDto(row: BindingRow): Promise<UserBinding> {
|
||||
const meta = await serverMeta(row.serverId)
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
serverId: row.serverId,
|
||||
serverName: meta.name,
|
||||
serverSite: meta.site,
|
||||
serverCountry: meta.country,
|
||||
interfaceName: row.interfaceName,
|
||||
interfaceType: row.interfaceType,
|
||||
peerPublicKey: row.peerPublicKey ?? "",
|
||||
peerName: row.peerName ?? "",
|
||||
comment: row.comment,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function toUserDto(row: AppUserRow, bindings: BindingRow[]): Promise<AppUserRead> {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
login: row.login,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
active: Boolean(row.active),
|
||||
avatar: row.avatar,
|
||||
lastSeen: row.lastSeen ?? null,
|
||||
sections: asPermArray<SectionPerm>(row.sectionsJson, []),
|
||||
servers: asPermArray<ServerPerm>(row.serversJson, []),
|
||||
bindings: await Promise.all(bindings.map((b) => toBindingDto(b))),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<AppUserRead[]> {
|
||||
const users = await listUserRows()
|
||||
const allBindings = await listBindingRows()
|
||||
const byUser = new Map<string, BindingRow[]>()
|
||||
for (const b of allBindings) {
|
||||
const arr = byUser.get(b.userId) ?? []
|
||||
arr.push(b)
|
||||
byUser.set(b.userId, arr)
|
||||
}
|
||||
return Promise.all(users.map((u) => toUserDto(u, byUser.get(u.id) ?? [])))
|
||||
}
|
||||
|
||||
export async function getUserById(id: string): Promise<AppUserRead | undefined> {
|
||||
const row = await getUserRowById(id)
|
||||
if (!row) return undefined
|
||||
return await toUserDto(row, await listBindingRowsByUser(id))
|
||||
}
|
||||
|
||||
export async function createUser(input: AppUserCreate): Promise<AppUserRead> {
|
||||
const login = input.login.trim()
|
||||
if (await getUserRowByLogin(login)) {
|
||||
throw new UsersServiceError("Логин уже занят", 409)
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const row = await createUserRow({
|
||||
id: randomUUID(),
|
||||
name: input.name.trim(),
|
||||
login,
|
||||
email: input.email.trim(),
|
||||
role: input.role ?? "viewer",
|
||||
active: input.active ?? true,
|
||||
avatar: (input.avatar ?? "").trim() || initials(input.name),
|
||||
lastSeen: input.lastSeen ?? null,
|
||||
sectionsJson: input.sections ?? [],
|
||||
serversJson: input.servers ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return await toUserDto(row, [])
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, input: AppUserUpdate): Promise<AppUserRead> {
|
||||
const existing = await getUserRowById(id)
|
||||
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
|
||||
if (input.login != null) {
|
||||
const other = await getUserRowByLogin(input.login.trim())
|
||||
if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409)
|
||||
}
|
||||
const patch: Partial<AppUserRow> = { updatedAt: new Date().toISOString() }
|
||||
if (input.name != null) patch.name = input.name.trim()
|
||||
if (input.login != null) patch.login = input.login.trim()
|
||||
if (input.email != null) patch.email = input.email.trim()
|
||||
if (input.role != null) patch.role = input.role
|
||||
if (input.active != null) patch.active = input.active
|
||||
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
|
||||
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
|
||||
if (input.sections != null) patch.sectionsJson = input.sections
|
||||
if (input.servers != null) patch.serversJson = input.servers
|
||||
const updated = await updateUserRowById(id, patch)
|
||||
return await toUserDto(updated, await listBindingRowsByUser(id))
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<void> {
|
||||
const existing = await getUserRowById(id)
|
||||
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
|
||||
await deleteUserRowById(id)
|
||||
}
|
||||
|
||||
export async function addBinding(userId: string, input: UserBindingCreate): Promise<UserBinding> {
|
||||
const user = await getUserRowById(userId)
|
||||
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
|
||||
const serverRows = await db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1)
|
||||
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
|
||||
const ifaceName = input.interfaceName.trim()
|
||||
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
||||
const type: InterfaceType = input.interfaceType ?? await inferIfaceType(input.serverId, ifaceName)
|
||||
let peerPublicKey = ""
|
||||
try {
|
||||
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
|
||||
} catch (err) {
|
||||
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
|
||||
throw err
|
||||
}
|
||||
const peerName = type === "wg"
|
||||
? peerDisplayName({
|
||||
publicKey: peerPublicKey,
|
||||
name: input.peerName,
|
||||
})
|
||||
: ""
|
||||
const taken = await getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
|
||||
if (taken) {
|
||||
throw new UsersServiceError(
|
||||
type === "wg"
|
||||
? "Этот пир уже привязан к другому пользователю"
|
||||
: "Интерфейс уже привязан к другому пользователю",
|
||||
409,
|
||||
)
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
const row = await createBindingRow({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
serverId: input.serverId,
|
||||
interfaceName: ifaceName,
|
||||
interfaceType: type,
|
||||
peerPublicKey,
|
||||
peerName,
|
||||
comment: (input.comment ?? "").trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return await toBindingDto(row)
|
||||
} catch (err) {
|
||||
if (isUniqueConstraintError(err)) {
|
||||
throw new UsersServiceError(
|
||||
type === "wg"
|
||||
? "Этот пир уже привязан к другому пользователю"
|
||||
: "Интерфейс уже привязан к другому пользователю",
|
||||
409,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeBinding(userId: string, bindingId: string): Promise<void> {
|
||||
const row = await getBindingRowById(bindingId)
|
||||
if (!row || row.userId !== userId) {
|
||||
throw new UsersServiceError("Привязка не найдена", 404)
|
||||
}
|
||||
await deleteBindingRowById(bindingId)
|
||||
}
|
||||
|
||||
async function inferIfaceType(serverId: number, ifaceName: string): Promise<InterfaceType> {
|
||||
const snap = await getLatestSnapshot(serverId)
|
||||
const parsed = parseRawInterfaces(snap?.rawInterfaces)
|
||||
const found = parsed.find((i) => i.name === ifaceName)
|
||||
return found?.type ?? "other"
|
||||
}
|
||||
|
||||
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
|
||||
const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
|
||||
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
|
||||
|
||||
const snap = await getLatestSnapshot(serverId)
|
||||
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
|
||||
if (ifaces.length === 0) {
|
||||
const lastRows = await db
|
||||
.select({ sampledAt: trafficSamples.sampledAt })
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId))
|
||||
.orderBy(desc(trafficSamples.sampledAt))
|
||||
.limit(1)
|
||||
const last = lastRows[0]
|
||||
if (last) {
|
||||
const rows = (await db
|
||||
.select({
|
||||
interfaceName: trafficSamples.interfaceName,
|
||||
peerPublicKey: trafficSamples.peerPublicKey,
|
||||
running: trafficSamples.running,
|
||||
disabled: trafficSamples.disabled,
|
||||
})
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId)))
|
||||
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
|
||||
const seen = new Set<string>()
|
||||
ifaces = []
|
||||
for (const r of rows) {
|
||||
if (seen.has(r.interfaceName)) continue
|
||||
seen.add(r.interfaceName)
|
||||
ifaces.push({
|
||||
name: r.interfaceName,
|
||||
type: mapRosInterfaceType("", r.interfaceName),
|
||||
running: Boolean(r.running),
|
||||
disabled: Boolean(r.disabled),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bindings = (await listBindingRows()).filter((b) => b.serverId === serverId)
|
||||
const usersById = new Map((await listUserRows()).map((u) => [u.id, u]))
|
||||
const hasWg = ifaces.some((i) => i.type === "wg")
|
||||
const wgLive = hasWg
|
||||
? await listWireGuardPeersForCatalog(serverId)
|
||||
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
|
||||
const peersByIface = new Map<string, typeof wgLive.peers>()
|
||||
for (const peer of wgLive.peers) {
|
||||
const list = peersByIface.get(peer.interfaceName) ?? []
|
||||
list.push(peer)
|
||||
peersByIface.set(peer.interfaceName, list)
|
||||
}
|
||||
|
||||
return ifaces.map((iface) => {
|
||||
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
|
||||
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
|
||||
const base: CatalogInterface = {
|
||||
name: iface.name,
|
||||
type: iface.type,
|
||||
running: iface.running,
|
||||
disabled: iface.disabled,
|
||||
boundUserId: ifaceBind?.userId ?? null,
|
||||
boundUserLogin: owner?.login ?? null,
|
||||
}
|
||||
if (iface.type !== "wg") return base
|
||||
const livePeers = peersByIface.get(iface.name) ?? []
|
||||
return {
|
||||
...base,
|
||||
peersError: wgLive.error,
|
||||
peers: livePeers.map((p) => {
|
||||
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
|
||||
const peerOwner = bind ? usersById.get(bind.userId) : undefined
|
||||
return {
|
||||
publicKey: p.publicKey,
|
||||
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
|
||||
comment: p.comment,
|
||||
allowedIps: p.allowedIps,
|
||||
latestHandshake: p.latestHandshake,
|
||||
boundUserId: bind?.userId ?? null,
|
||||
boundUserLogin: peerOwner?.login ?? null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export { parseRawInterfaces, mapRosInterfaceType }
|
||||
@@ -10,7 +10,6 @@ process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
|
||||
process.env.AUTH_ISSUER = "https://auth.test.local"
|
||||
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
|
||||
process.env.CORS_ORIGIN = "http://localhost:3000"
|
||||
process.env.DATABASE_PATH = ":memory:"
|
||||
process.env.NODE_ENV = "test"
|
||||
|
||||
// Dynamic import after env is set
|
||||
|
||||
@@ -20,14 +20,14 @@ import {
|
||||
|
||||
const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/alerts", async (_req, reply) => {
|
||||
const tg = getTelegramPublic()
|
||||
const tg = await getTelegramPublic()
|
||||
const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim())
|
||||
return reply.send({
|
||||
telegram: { ...tg, connected },
|
||||
groups: listAlertGroups(),
|
||||
rules: listAlertRules(),
|
||||
history: listMergedHistory(),
|
||||
meta: getAlertsMeta(),
|
||||
groups: await listAlertGroups(),
|
||||
rules: await listAlertRules(),
|
||||
history: await listMergedHistory(),
|
||||
meta: await getAlertsMeta(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
const groups =
|
||||
parsed.data.groups ??
|
||||
listAlertGroups().map((g) => ({
|
||||
(await listAlertGroups()).map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
combineMode: g.combineMode,
|
||||
@@ -67,8 +67,8 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
|
||||
chatId: r.chatId,
|
||||
}))
|
||||
replaceAlertsConfig({ groups, rules })
|
||||
return reply.send({ ok: true, groups: listAlertGroups(), rules: listAlertRules() })
|
||||
await replaceAlertsConfig({ groups, rules })
|
||||
return reply.send({ ok: true, groups: await listAlertGroups(), rules: await listAlertRules() })
|
||||
})
|
||||
|
||||
app.put("/alerts/telegram", async (req, reply) => {
|
||||
@@ -76,7 +76,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const next = updateTelegramSettings({
|
||||
const next = await updateTelegramSettings({
|
||||
token: parsed.data.token,
|
||||
chatId: parsed.data.chatId,
|
||||
messageThreadId: parsed.data.messageThreadId,
|
||||
@@ -90,9 +90,9 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const fromDb = getTelegramBotToken()
|
||||
const fromDb = await getTelegramBotToken()
|
||||
const token = (parsed.data.token?.trim() || fromDb).trim()
|
||||
const pub = getTelegramPublic()
|
||||
const pub = await getTelegramPublic()
|
||||
const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim()
|
||||
if (!token) {
|
||||
return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" })
|
||||
@@ -103,7 +103,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const threadId =
|
||||
parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1
|
||||
? parsed.data.messageThreadId
|
||||
: getTelegramMessageThreadIdForApi()
|
||||
: await getTelegramMessageThreadIdForApi()
|
||||
const text = parsed.data.rulePreview
|
||||
? formatAlertRuleTestTelegramText(parsed.data.rulePreview)
|
||||
: "MikroTik Manager — тест оповещений (Telegram)."
|
||||
|
||||
@@ -61,7 +61,7 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||
}
|
||||
job.status = "done"
|
||||
job.finishedAt = new Date().toISOString()
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: job.failures.length > 0 ? "warning" : "info",
|
||||
eventType: "backups.job.done",
|
||||
sourceModule: "backups",
|
||||
@@ -80,11 +80,11 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||
|
||||
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/backups", async (_req, reply) => {
|
||||
return reply.send(listBackups())
|
||||
return reply.send(await listBackups())
|
||||
})
|
||||
|
||||
app.get("/backups/schedule", async (_req, reply) => {
|
||||
return reply.send(getBackupScheduleSettings())
|
||||
return reply.send(await getBackupScheduleSettings())
|
||||
})
|
||||
|
||||
app.put("/backups/schedule", async (req, reply) => {
|
||||
@@ -92,15 +92,15 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const result = updateBackupScheduleSettings(parsed.data)
|
||||
refreshScheduler()
|
||||
const result = await updateBackupScheduleSettings(parsed.data)
|
||||
await refreshScheduler()
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
|
||||
const inputIds = req.body.serverIds.map((x) => String(x))
|
||||
const notes = req.body.notes?.trim() || undefined
|
||||
const existingServers = new Set(listServersRead().map((s) => String(s.id)))
|
||||
const existingServers = new Set((await listServersRead()).map((s) => String(s.id)))
|
||||
const ids = [...new Set(inputIds)].filter((id) => existingServers.has(id))
|
||||
if (ids.length === 0) return reply.status(400).send({ error: "Не выбраны валидные серверы" })
|
||||
|
||||
@@ -115,7 +115,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
failures: [],
|
||||
}
|
||||
backupJobs.set(jobId, job)
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "info",
|
||||
eventType: "backups.job.started",
|
||||
sourceModule: "backups",
|
||||
@@ -128,8 +128,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
notes: notes ?? null,
|
||||
},
|
||||
})
|
||||
queueMicrotask(() => {
|
||||
void processBackupJob(job, ids, notes).catch((err) => {
|
||||
queueMicrotask(async () => {
|
||||
void processBackupJob(job, ids, notes).catch(async (err) => {
|
||||
job.status = "failed"
|
||||
job.finishedAt = new Date().toISOString()
|
||||
const failure = {
|
||||
@@ -137,7 +137,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
job.failures.push(failure)
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "critical",
|
||||
eventType: "backups.job.failed",
|
||||
sourceModule: "backups",
|
||||
@@ -167,7 +167,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
const hit = getBackupById(req.params.id)
|
||||
const hit = await getBackupById(req.params.id)
|
||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||
const filePath = path.join(getBackupsDir(), hit.filename)
|
||||
const content = await readFile(filePath, "utf8").catch(() => null)
|
||||
|
||||
@@ -12,7 +12,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug)
|
||||
app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = db.select().from(servers).where(eq(servers.id, params.id)).limit(1).all()[0]
|
||||
const server = (await db.select().from(servers).where(eq(servers.id, params.id)).limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
@@ -26,7 +26,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// GET /api/bgp/sessions — aggregate from ALL enabled servers
|
||||
app.get("/bgp/sessions", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
|
||||
const results: BgpSessionRead[][] = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
@@ -46,10 +46,10 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
// GET /api/servers/:id/bgp/sessions — single server
|
||||
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = db
|
||||
const server = (await db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, params.id))
|
||||
.limit(1).all()[0]
|
||||
.limit(1))[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.get("/certificates/acme-settings", async (_req, reply) => {
|
||||
return reply.send(getAcmeSettingsPublic())
|
||||
return reply.send(await getAcmeSettingsPublic())
|
||||
})
|
||||
|
||||
app.put("/certificates/acme-settings", async (req, reply) => {
|
||||
@@ -40,11 +40,11 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
return reply.send(updateAcmeSettings(parsed.data))
|
||||
return reply.send(await updateAcmeSettings(parsed.data))
|
||||
})
|
||||
|
||||
app.get("/certificates/renew-settings", async (_req, reply) => {
|
||||
return reply.send(getCertificateRenewSettings())
|
||||
return reply.send(await getCertificateRenewSettings())
|
||||
})
|
||||
|
||||
app.put("/certificates/renew-settings", async (req, reply) => {
|
||||
@@ -52,8 +52,8 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const result = updateCertificateRenewSettings(parsed.data)
|
||||
refreshScheduler()
|
||||
const result = await updateCertificateRenewSettings(parsed.data)
|
||||
await refreshScheduler()
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
@@ -62,7 +62,7 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const effective = parsed.data.cloudflareApiToken?.trim() || getAcmeCloudflareToken()
|
||||
const effective = parsed.data.cloudflareApiToken?.trim() || await getAcmeCloudflareToken()
|
||||
if (!effective) {
|
||||
return reply.status(400).send({ error: "Не задан Cloudflare API token" })
|
||||
}
|
||||
@@ -83,12 +83,12 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const serverId = String(parsed.data.serverId)
|
||||
const server = getServerRowByIdString(serverId)
|
||||
const server = await getServerRowByIdString(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
|
||||
const jobId = randomUUID()
|
||||
const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",")
|
||||
createIssueJobRecord({
|
||||
await createIssueJobRecord({
|
||||
id: jobId,
|
||||
serverId,
|
||||
certName: parsed.data.certName.trim(),
|
||||
@@ -103,9 +103,9 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
app.get("/certificates/issue/:jobId", async (req, reply) => {
|
||||
const jobId = String((req.params as { jobId: string }).jobId)
|
||||
const row = getIssueJobRecord(jobId)
|
||||
const row = await getIssueJobRecord(jobId)
|
||||
if (!row) return reply.status(404).send({ error: "Задача не найдена" })
|
||||
return reply.send(toIssueJobDto(row))
|
||||
return reply.send(await toIssueJobDto(row))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректные параметры запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
return reply.send({ events: readEvents(parsed.data) })
|
||||
return reply.send({ events: await readEvents(parsed.data) })
|
||||
})
|
||||
|
||||
app.post("/events/batch", async (req, reply) => {
|
||||
@@ -16,7 +16,7 @@ const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
appendEvents(parsed.data.events)
|
||||
await appendEvents(parsed.data.events)
|
||||
return reply.status(201).send({ ok: true, inserted: parsed.data.events.length })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ const TestEvobgpSchema = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
function ensureEvobgpRow() {
|
||||
let row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
|
||||
async function ensureEvobgpRow() {
|
||||
let row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0]
|
||||
if (!row) {
|
||||
db.insert(evobgpSettings).values({ id: 1 }).run()
|
||||
row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
|
||||
await db.insert(evobgpSettings).values({ id: 1 })
|
||||
row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0]
|
||||
}
|
||||
return row!
|
||||
}
|
||||
@@ -108,11 +108,10 @@ function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map<string, numb
|
||||
* Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN).
|
||||
* Ключ — значение `filter_rules.community` после trim (как в EvoBGP `community`).
|
||||
*/
|
||||
function distinctServersByFilterCommunityValue(): Map<string, number> {
|
||||
const rows = db
|
||||
async function distinctServersByFilterCommunityValue(): Promise<Map<string, number>> {
|
||||
const rows = await db
|
||||
.select({ serverId: filterRules.serverId, community: filterRules.community })
|
||||
.from(filterRules)
|
||||
.all()
|
||||
const byVal = new Map<string, Set<number>>()
|
||||
for (const r of rows) {
|
||||
const v = (r.community ?? "").trim()
|
||||
@@ -161,8 +160,8 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
|
||||
}
|
||||
}
|
||||
|
||||
function credentialsFromDb(): { root: string; apiKey: string } | null {
|
||||
const row = ensureEvobgpRow()
|
||||
async function credentialsFromDb(): Promise<{ root: string; apiKey: string } | null> {
|
||||
const row = await ensureEvobgpRow()
|
||||
const root = normalizeBaseUrl(row.baseUrl)
|
||||
const apiKey = normalizeApiKey(row.apiKey)
|
||||
if (!root || !apiKey) return null
|
||||
@@ -171,7 +170,7 @@ function credentialsFromDb(): { root: string; apiKey: string } | null {
|
||||
|
||||
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/evobgp/settings", async (_req, reply) => {
|
||||
const row = ensureEvobgpRow()
|
||||
const row = await ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: Boolean(row.enabled),
|
||||
@@ -184,7 +183,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const cur = ensureEvobgpRow()
|
||||
const cur = await ensureEvobgpRow()
|
||||
let nextBase = cur.baseUrl
|
||||
let nextEnabled = cur.enabled
|
||||
let nextKey = cur.apiKey
|
||||
@@ -200,17 +199,16 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
: normalizeApiKey(parsed.data.apiKey)
|
||||
}
|
||||
|
||||
db.update(evobgpSettings)
|
||||
await db.update(evobgpSettings)
|
||||
.set({
|
||||
baseUrl: nextBase,
|
||||
enabled: nextEnabled,
|
||||
apiKey: nextKey,
|
||||
updatedAt: sql`(datetime('now'))`,
|
||||
updatedAt: sql`now()`,
|
||||
})
|
||||
.where(eq(evobgpSettings.id, 1))
|
||||
.run()
|
||||
|
||||
const row = ensureEvobgpRow()
|
||||
const row = await ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: Boolean(row.enabled),
|
||||
@@ -228,7 +226,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const row = ensureEvobgpRow()
|
||||
const row = await ensureEvobgpRow()
|
||||
const d = parsed.data
|
||||
const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl
|
||||
const keyRaw =
|
||||
@@ -253,11 +251,11 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
* Прокси к GET /v1/router-lists/catalog — учётные данные только из БД.
|
||||
*/
|
||||
app.post("/evobgp/catalog", async (_req, reply) => {
|
||||
const row = ensureEvobgpRow()
|
||||
const row = await ensureEvobgpRow()
|
||||
if (!row.enabled) {
|
||||
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
|
||||
}
|
||||
const cred = credentialsFromDb()
|
||||
const cred = await credentialsFromDb()
|
||||
if (!cred) {
|
||||
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
|
||||
}
|
||||
@@ -333,7 +331,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
|
||||
const serversByCommunityStr = distinctServersByFilterCommunityValue()
|
||||
const serversByCommunityStr = await distinctServersByFilterCommunityValue()
|
||||
|
||||
const communities = commItems.map((c) => {
|
||||
const valueStr = (c.community ?? "").trim()
|
||||
|
||||
@@ -314,10 +314,10 @@ const execRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
|
||||
async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = db
|
||||
const server = (await db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, params.id))
|
||||
.limit(1).all()[0]
|
||||
.limit(1))[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
|
||||
@@ -186,12 +186,11 @@ function gatewayFromRecursiveDst(dstAddress: string): string {
|
||||
* Импорт с роутера даёт только `set gateway` без `set out-interface` — в таком виде не отличить от «поломанного» GRE.
|
||||
* Сопоставляем hop с локальной таблицей recursive_routes и восстанавливаем gatewayTunnelId = rec:<id>.
|
||||
*/
|
||||
function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): ApiFilterRule[] {
|
||||
const rows = db
|
||||
async function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): Promise<ApiFilterRule[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(recursiveRoutes)
|
||||
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.disabled, false)))
|
||||
.all()
|
||||
|
||||
return rules.map(rule => {
|
||||
if (rule.action === "blackhole") return rule
|
||||
@@ -249,7 +248,7 @@ async function fetchServerFilters(server: ServerRow) {
|
||||
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
|
||||
.flatMap(parseFilterRule)
|
||||
|
||||
const rules = enrichRulesWithRecursiveGateway(server.id, rulesRaw)
|
||||
const rules = await enrichRulesWithRecursiveGateway(server.id, rulesRaw)
|
||||
|
||||
return {
|
||||
serverId: String(server.id),
|
||||
@@ -258,8 +257,8 @@ async function fetchServerFilters(server: ServerRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function toApiRulesets(serverRows: ServerRow[]) {
|
||||
const dbRules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all()
|
||||
async function toApiRulesets(serverRows: ServerRow[]) {
|
||||
const dbRules = await db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder))
|
||||
return serverRows.map(s => ({
|
||||
serverId: String(s.id),
|
||||
rules: dbRules
|
||||
@@ -277,7 +276,7 @@ function toApiRulesets(serverRows: ServerRow[]) {
|
||||
}
|
||||
|
||||
/** GRE: gatewayTunnelId = имя интерфейса; рекурсивный: rec:<id строки recursive_routes */
|
||||
function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway: string; outIface: string } {
|
||||
async function resolveRouteTargets(serverId: number, rule: ApiFilterRule): Promise<{ gateway: string; outIface: string }> {
|
||||
if (rule.action === "blackhole") return { gateway: "", outIface: "" }
|
||||
const tid = (rule.gatewayTunnelId ?? "").trim()
|
||||
if (tid.startsWith("rec:")) {
|
||||
@@ -285,12 +284,11 @@ function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway:
|
||||
if (!Number.isFinite(rid)) {
|
||||
return { gateway: rule.gateway, outIface: "" }
|
||||
}
|
||||
const row = db
|
||||
const row = (await db
|
||||
.select()
|
||||
.from(recursiveRoutes)
|
||||
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.id, rid)))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
.limit(1))[0]
|
||||
if (!row) return { gateway: rule.gateway, outIface: "" }
|
||||
const gw = gatewayFromRecursiveDst(row.dstAddress)
|
||||
return { gateway: gw || rule.gateway, outIface: "" }
|
||||
@@ -303,30 +301,30 @@ function normalizeCommunity(c: string): string {
|
||||
}
|
||||
|
||||
/** Одинаковый эффект на роутере при одинаковой community (blackhole vs gateway + out-interface) */
|
||||
function ruleEffectSignature(serverId: number, r: ApiFilterRule): string {
|
||||
async function ruleEffectSignature(serverId: number, r: ApiFilterRule): Promise<string> {
|
||||
if (r.action === "blackhole") return `bh:${normalizeCommunity(r.community)}`
|
||||
const { gateway, outIface } = resolveRouteTargets(serverId, r)
|
||||
const { gateway, outIface } = await resolveRouteTargets(serverId, r)
|
||||
return `rt:${normalizeCommunity(r.community)}:${gateway}:${outIface}`
|
||||
}
|
||||
|
||||
export type FilterRouterCompareStatus = "synced" | "drift" | "missing"
|
||||
|
||||
function compareDbRulesWithRouter(
|
||||
async function compareDbRulesWithRouter(
|
||||
serverId: number,
|
||||
dbRules: ApiFilterRule[],
|
||||
remoteRules: ApiFilterRule[],
|
||||
): Record<string, FilterRouterCompareStatus> {
|
||||
): Promise<Record<string, FilterRouterCompareStatus>> {
|
||||
const remoteSigByComm = new Map<string, string>()
|
||||
for (const rr of remoteRules) {
|
||||
const c = normalizeCommunity(rr.community)
|
||||
if (!remoteSigByComm.has(c)) {
|
||||
remoteSigByComm.set(c, ruleEffectSignature(serverId, rr))
|
||||
remoteSigByComm.set(c, await ruleEffectSignature(serverId, rr))
|
||||
}
|
||||
}
|
||||
const out: Record<string, FilterRouterCompareStatus> = {}
|
||||
for (const dr of dbRules) {
|
||||
const c = normalizeCommunity(dr.community)
|
||||
const sigD = ruleEffectSignature(serverId, dr)
|
||||
const sigD = await ruleEffectSignature(serverId, dr)
|
||||
const sigR = remoteSigByComm.get(c)
|
||||
if (sigR === undefined) {
|
||||
out[c] = "missing"
|
||||
@@ -339,7 +337,7 @@ function compareDbRulesWithRouter(
|
||||
return out
|
||||
}
|
||||
|
||||
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
|
||||
async function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): Promise<string> {
|
||||
if (rules.length === 0) return ""
|
||||
// Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же
|
||||
// `set gw` объединяются через `||` в один if-блок — компактнее и ближе к привычному
|
||||
@@ -361,7 +359,7 @@ function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
|
||||
const isBlackhole = rule.action === "blackhole"
|
||||
const { gateway, outIface } = isBlackhole
|
||||
? { gateway: "", outIface: "" }
|
||||
: resolveRouteTargets(serverId, rule)
|
||||
: await resolveRouteTargets(serverId, rule)
|
||||
const key = isBlackhole ? "bh" : `rt:${gateway}:${outIface}`
|
||||
let idx = indexByKey.get(key)
|
||||
if (idx === undefined) {
|
||||
@@ -404,10 +402,10 @@ function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
|
||||
}
|
||||
|
||||
async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
|
||||
db.delete(filterRules).where(eq(filterRules.serverId, serverId)).run()
|
||||
await db.delete(filterRules).where(eq(filterRules.serverId, serverId))
|
||||
if (rules.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
db.insert(filterRules).values(
|
||||
await db.insert(filterRules).values(
|
||||
rules.map((r, i) => ({
|
||||
serverId,
|
||||
sortOrder: i,
|
||||
@@ -420,7 +418,7 @@ async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
).run()
|
||||
)
|
||||
}
|
||||
|
||||
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
@@ -432,17 +430,16 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "serverId is required" })
|
||||
}
|
||||
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
const remote = await fetchServerFilters(server)
|
||||
const rows = db
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(filterRules)
|
||||
.where(eq(filterRules.serverId, serverId))
|
||||
.orderBy(asc(filterRules.sortOrder))
|
||||
.all()
|
||||
|
||||
const dbRules: ApiFilterRule[] = rows.map(r => ({
|
||||
id: String(r.id),
|
||||
@@ -454,7 +451,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
description: r.description,
|
||||
}))
|
||||
|
||||
const byCommunity = compareDbRulesWithRouter(serverId, dbRules, remote.rules)
|
||||
const byCommunity = await compareDbRulesWithRouter(serverId, dbRules, remote.rules)
|
||||
return reply.send({ byCommunity })
|
||||
} catch (err) {
|
||||
app.log.error({ serverId, err: String(err) }, "filters router-compare failed")
|
||||
@@ -466,7 +463,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/filters/gre-tunnels", async (req, reply) => {
|
||||
const q = req.query as { serverId?: string | number }
|
||||
const sid = parseDbServerId(q.serverId)
|
||||
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
|
||||
if (sid !== null) {
|
||||
const server = enabledServers.find(s => s.id === sid)
|
||||
@@ -492,8 +489,8 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
/** Только правила фильтров из БД (без опроса MikroTik за GRE) */
|
||||
app.get("/filters/rules", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const dbRulesets = toApiRulesets(allServers)
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const dbRulesets = await toApiRulesets(allServers)
|
||||
|
||||
return reply.send({
|
||||
rulesets: dbRulesets,
|
||||
@@ -506,7 +503,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const payload = body.rulesets ?? []
|
||||
const serverIds = payload.map(r => Number.parseInt(r.serverId, 10)).filter(Number.isFinite)
|
||||
if (serverIds.length > 0) {
|
||||
db.delete(filterRules).where(inArray(filterRules.serverId, serverIds)).run()
|
||||
await db.delete(filterRules).where(inArray(filterRules.serverId, serverIds))
|
||||
}
|
||||
for (const rs of payload) {
|
||||
const sid = Number.parseInt(rs.serverId, 10)
|
||||
@@ -524,12 +521,12 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "serverId is required" })
|
||||
}
|
||||
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "info",
|
||||
eventType: "filters.sync.from_router.started",
|
||||
sourceModule: "filters",
|
||||
@@ -541,7 +538,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const remote = await fetchServerFilters(server)
|
||||
await replaceDbRules(server.id, remote.rules)
|
||||
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed")
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "info",
|
||||
eventType: "filters.sync.from_router.done",
|
||||
sourceModule: "filters",
|
||||
@@ -553,7 +550,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId })
|
||||
} catch (err) {
|
||||
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "critical",
|
||||
eventType: "filters.sync.from_router.failed",
|
||||
sourceModule: "filters",
|
||||
@@ -570,7 +567,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const body = req.body as { serverId?: string | number } | undefined
|
||||
const requestedServerId = parseDbServerId(body?.serverId)
|
||||
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const targetServers = requestedServerId !== null
|
||||
? allServers.filter(s => s.id === requestedServerId)
|
||||
: allServers
|
||||
@@ -582,7 +579,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
let updatedServers = 0
|
||||
let pushedRules = 0
|
||||
const errors: Array<{ serverId: number; error: string }> = []
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "info",
|
||||
eventType: "filters.sync.to_router.started",
|
||||
sourceModule: "filters",
|
||||
@@ -618,10 +615,9 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
.map(r => r[".id"])
|
||||
.filter((id): id is string => Boolean(id))
|
||||
|
||||
const rows = db.select().from(filterRules)
|
||||
const rows = await db.select().from(filterRules)
|
||||
.where(and(eq(filterRules.serverId, server.id)))
|
||||
.orderBy(asc(filterRules.sortOrder))
|
||||
.all()
|
||||
|
||||
const rules: ApiFilterRule[] = rows.map(r => ({
|
||||
id: String(r.id),
|
||||
@@ -639,7 +635,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
// трактует как «вызов команды» и отдаёт 400 «no such command».
|
||||
// См. https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
|
||||
if (rules.length > 0) {
|
||||
const ruleBody = toRouterRuleBody(server.id, rules)
|
||||
const ruleBody = await toRouterRuleBody(server.id, rules)
|
||||
if (managedRule && managedRule[".id"]) {
|
||||
await client.patch(
|
||||
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
||||
@@ -688,7 +684,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
}
|
||||
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: errors.length === 0 ? "info" : "warning",
|
||||
eventType: errors.length === 0 ? "filters.sync.to_router.done" : "filters.sync.to_router.partial",
|
||||
sourceModule: "filters",
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
|
||||
import { getEnabledServerById } from "../services/wireguard-live.js"
|
||||
import { listFirewallAll } from "../services/firewall-live.js"
|
||||
import type { FirewallFamily, FirewallTable } from "../types/server.js"
|
||||
|
||||
const FamilySchema = z.enum(["ip", "ip6"])
|
||||
const TableSchema = z.enum(["filter", "nat", "mangle", "raw"])
|
||||
|
||||
const RuleKeySchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
family: FamilySchema,
|
||||
table: TableSchema,
|
||||
rosId: z.string().min(1),
|
||||
})
|
||||
|
||||
const RuleWriteSchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
family: FamilySchema,
|
||||
table: TableSchema,
|
||||
rosId: z.string().min(1).optional(),
|
||||
chain: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
protocol: z.string().optional(),
|
||||
srcAddress: z.string().optional(),
|
||||
dstAddress: z.string().optional(),
|
||||
srcAddressList: z.string().optional(),
|
||||
dstAddressList: z.string().optional(),
|
||||
srcPort: z.string().optional(),
|
||||
dstPort: z.string().optional(),
|
||||
inInterface: z.string().optional(),
|
||||
outInterface: z.string().optional(),
|
||||
connectionState: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
log: z.boolean().optional(),
|
||||
logPrefix: z.string().optional(),
|
||||
tlsHost: z.string().optional(),
|
||||
layer7Proto: z.string().optional(),
|
||||
})
|
||||
|
||||
const RulePatchSchema = RuleKeySchema.extend({
|
||||
disabled: z.boolean(),
|
||||
})
|
||||
|
||||
const RuleMoveSchema = RuleKeySchema.extend({
|
||||
destinationRosId: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
const AddressKeySchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
family: FamilySchema,
|
||||
rosId: z.string().min(1),
|
||||
})
|
||||
|
||||
const AddressWriteSchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
family: FamilySchema,
|
||||
rosId: z.string().min(1).optional(),
|
||||
list: z.string().min(1),
|
||||
address: z.string().min(1),
|
||||
comment: z.string().optional(),
|
||||
timeout: z.string().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const AddressPatchSchema = AddressKeySchema.extend({
|
||||
disabled: z.boolean(),
|
||||
})
|
||||
|
||||
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v !== undefined && v !== "") out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function ruleToRos(d: z.infer<typeof RuleWriteSchema>): Record<string, string> {
|
||||
return toRosBody({
|
||||
chain: d.chain,
|
||||
action: d.action,
|
||||
protocol: d.protocol && d.protocol !== "all" ? d.protocol : undefined,
|
||||
"src-address": d.srcAddress,
|
||||
"dst-address": d.dstAddress,
|
||||
"src-address-list": d.srcAddressList,
|
||||
"dst-address-list": d.dstAddressList,
|
||||
"src-port": d.srcPort,
|
||||
"dst-port": d.dstPort,
|
||||
"in-interface": d.inInterface,
|
||||
"out-interface": d.outInterface,
|
||||
"connection-state": d.connectionState,
|
||||
comment: d.comment,
|
||||
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||
log: d.log === true ? "yes" : d.log === false ? "no" : undefined,
|
||||
"log-prefix": d.logPrefix,
|
||||
"tls-host": d.tlsHost,
|
||||
"layer7-protocol": d.layer7Proto,
|
||||
})
|
||||
}
|
||||
|
||||
function addressToRos(d: z.infer<typeof AddressWriteSchema>): Record<string, string> {
|
||||
return toRosBody({
|
||||
list: d.list,
|
||||
address: d.address,
|
||||
comment: d.comment,
|
||||
timeout: d.timeout,
|
||||
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function rosErr(e: unknown): string {
|
||||
if (e instanceof MikrotikError) return e.message
|
||||
if (e instanceof Error) return e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
async function requireServer(serverId: string) {
|
||||
return await getEnabledServerById(serverId)
|
||||
}
|
||||
|
||||
const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/firewall/all", async (_req, reply) => {
|
||||
const data = await listFirewallAll()
|
||||
return reply.send(data)
|
||||
})
|
||||
|
||||
app.post("/firewall/rules", async (req, reply) => {
|
||||
const parsed = RuleWriteSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
|
||||
try {
|
||||
await client.put(path, ruleToRos(body))
|
||||
return reply.status(201).send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.put("/firewall/rules", async (req, reply) => {
|
||||
const parsed = RuleWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.patch(path, ruleToRos(body))
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/firewall/rules", async (req, reply) => {
|
||||
const parsed = RulePatchSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/firewall/rules", async (req, reply) => {
|
||||
const parsed = RuleKeySchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.delete(path)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/firewall/rules/move", async (req, reply) => {
|
||||
const parsed = RuleMoveSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, body.table)}/move`
|
||||
try {
|
||||
await client.post(path, {
|
||||
numbers: body.rosId,
|
||||
...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
|
||||
})
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/firewall/address-lists", async (req, reply) => {
|
||||
const parsed = AddressWriteSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
|
||||
return reply.status(201).send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.put("/firewall/address-lists", async (req, reply) => {
|
||||
const parsed = AddressWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.patch(path, addressToRos(body))
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/firewall/address-lists", async (req, reply) => {
|
||||
const parsed = AddressPatchSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/firewall/address-lists", async (req, reply) => {
|
||||
const parsed = AddressKeySchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = await requireServer(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||
try {
|
||||
await client.delete(path)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default firewallRoutes
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/internet-path/settings", async (_req, reply) => {
|
||||
const s = getInternetPathSettings()
|
||||
const s = await getInternetPathSettings()
|
||||
return reply.send({
|
||||
enabled: s.enabled,
|
||||
intervalSec: s.intervalSec,
|
||||
@@ -25,12 +25,12 @@ const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
intervalSec?: number | string
|
||||
retentionDays?: number | string
|
||||
}
|
||||
const updated = updateInternetPathSettings({
|
||||
const updated = await updateInternetPathSettings({
|
||||
enabled: body.enabled,
|
||||
intervalSec: body.intervalSec == null ? undefined : Math.max(30, Number.parseInt(String(body.intervalSec), 10) || 300),
|
||||
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
|
||||
})
|
||||
refreshScheduler()
|
||||
await refreshScheduler()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
settings: {
|
||||
@@ -45,13 +45,15 @@ const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.get("/internet-path/latest", async (_req, reply) => {
|
||||
const row = getLatestInternetPathSnapshot()
|
||||
const row = await getLatestInternetPathSnapshot()
|
||||
if (!row) return reply.send({ snapshot: null })
|
||||
let payload: unknown = null
|
||||
try {
|
||||
payload = JSON.parse(row.payloadJson)
|
||||
} catch {
|
||||
payload = null
|
||||
let payload: unknown = row.payloadJson
|
||||
if (typeof row.payloadJson === "string") {
|
||||
try {
|
||||
payload = JSON.parse(row.payloadJson)
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
}
|
||||
return reply.send({
|
||||
snapshot: payload,
|
||||
|
||||
+23
-28
@@ -207,23 +207,21 @@ function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeig
|
||||
return Math.round(w * pingScore + (1 - w) * speedScore)
|
||||
}
|
||||
|
||||
function getLatestSnapshotLatencyMs(serverId: number): number {
|
||||
const latest = db
|
||||
async function getLatestSnapshotLatencyMs(serverId: number): Promise<number> {
|
||||
const latest = await db
|
||||
.select()
|
||||
.from(serverSnapshots)
|
||||
.where(eq(serverSnapshots.serverId, serverId))
|
||||
.orderBy(desc(serverSnapshots.polledAt))
|
||||
.limit(1)
|
||||
.all()
|
||||
return latest.length > 0 ? Math.max(1, Math.round(latest[0].latencyMs ?? 100)) : 100
|
||||
}
|
||||
|
||||
function latestTrafficByInterface(serverId: number): Map<string, TrafficSampleRow> {
|
||||
const rows = db
|
||||
async function latestTrafficByInterface(serverId: number): Promise<Map<string, { id: number; serverId: number; disabled: boolean; sampledAt: string; interfaceName: string; peerPublicKey: string; rxBytes: number; txBytes: number; rxBps: number; txBps: number; running: boolean; }>> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId))
|
||||
.all()
|
||||
const map = new Map<string, TrafficSampleRow>()
|
||||
for (const row of rows) {
|
||||
const prev = map.get(row.interfaceName)
|
||||
@@ -302,14 +300,14 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
|
||||
const areaMap = buildAreaMap(areas)
|
||||
const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
|
||||
const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface))
|
||||
const localLatencyMs = getLatestSnapshotLatencyMs(server.id)
|
||||
const latestTraffic = latestTrafficByInterface(server.id)
|
||||
const localLatencyMs = await getLatestSnapshotLatencyMs(server.id)
|
||||
const latestTraffic = await latestTrafficByInterface(server.id)
|
||||
|
||||
// 1) Сопоставляем remote OSPF router-id -> сервер из каталога (чтобы взять унифицированный ping как в карте /route-optimizer).
|
||||
const neededRouterIds = new Set(neighbors.map((n) => String(n["router-id"] ?? "")).filter((x) => x.length > 0))
|
||||
|
||||
async function buildRouterIdToServerMap(needed: Set<string>): Promise<Map<string, ServerRow["id"]>> {
|
||||
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const out = new Map<string, ServerRow["id"]>()
|
||||
const neededLeft = new Set(needed)
|
||||
|
||||
@@ -356,11 +354,10 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
|
||||
}
|
||||
|
||||
// 3) Предгружаем uptimeSpeedProbes: оттуда берём и ping, и скорость (единый источник как у карты).
|
||||
const allSourceProbes = db
|
||||
const allSourceProbes = (await db
|
||||
.select()
|
||||
.from(uptimeSpeedProbes)
|
||||
.where(eq(uptimeSpeedProbes.srcServerId, server.id))
|
||||
.all()
|
||||
.where(eq(uptimeSpeedProbes.srcServerId, server.id)))
|
||||
.filter((r: SpeedProbeRow) => r.enabled !== false)
|
||||
const speedProbesByDest = new Map<number, SpeedProbeRow[]>()
|
||||
for (const p of allSourceProbes) {
|
||||
@@ -482,8 +479,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
|
||||
for (const [iface, probe] of assigned.entries()) assignedProbeByIface.set(iface, probe)
|
||||
}
|
||||
|
||||
return editable
|
||||
.map((iface) => {
|
||||
return (await Promise.all(editable.map(async (iface) => {
|
||||
const t = latestTraffic.get(iface.interface)
|
||||
const speed = normalizeSpeedMbps(t)
|
||||
|
||||
@@ -494,7 +490,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
|
||||
bestProbe?.lastPingRttMs != null
|
||||
? Math.max(1, Math.round(bestProbe.lastPingRttMs))
|
||||
: dstServerId != null
|
||||
? Math.min(995, Math.round(localLatencyMs + getLatestSnapshotLatencyMs(dstServerId)))
|
||||
? Math.min(995, Math.round(localLatencyMs + await getLatestSnapshotLatencyMs(dstServerId)))
|
||||
: localLatencyMs
|
||||
|
||||
const dlMbps =
|
||||
@@ -517,8 +513,7 @@ async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number):
|
||||
score: calcRouteScore(pingMs, dlMbps, ulMbps, pingWeight),
|
||||
optimalCost: 0,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.score - a.score || a.interface.localeCompare(b.interface))
|
||||
}))).sort((a, b) => b.score - a.score || a.interface.localeCompare(b.interface))
|
||||
.map((row, idx) => ({ ...row, optimalCost: (idx + 1) * 10 }))
|
||||
}
|
||||
|
||||
@@ -528,7 +523,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers
|
||||
app.get("/ospf/neighbors", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
|
||||
const results = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
@@ -547,7 +542,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// GET /api/ospf/interfaces — aggregate OSPF interface templates from ALL enabled servers
|
||||
app.get("/ospf/interfaces", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
|
||||
const results = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
@@ -566,7 +561,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// GET /api/ospf/instances — aggregate OSPF instances from ALL enabled servers
|
||||
app.get("/ospf/instances", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
|
||||
const results = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
@@ -584,7 +579,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// GET /api/ospf/all — single round-trip: neighbors + interfaces + instances + BFD
|
||||
app.get("/ospf/all", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
|
||||
const perServer = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
@@ -613,7 +608,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// GET /api/bfd/sessions — BFD sessions only (for direct access)
|
||||
app.get("/bfd/sessions", async (_req, reply) => {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const results = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
@@ -631,10 +626,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
// GET /api/servers/:id/ospf — single server OSPF + BFD data
|
||||
app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = db
|
||||
const server = (await db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, params.id))
|
||||
.limit(1).all()[0]
|
||||
.limit(1))[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
@@ -659,10 +654,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
|
||||
async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = db
|
||||
const server = (await db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, params.id))
|
||||
.limit(1).all()[0]
|
||||
.limit(1))[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
@@ -691,10 +686,10 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
|
||||
async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = db
|
||||
const server = (await db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, params.id))
|
||||
.limit(1).all()[0]
|
||||
.limit(1))[0]
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
|
||||
@@ -256,14 +256,14 @@ async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: str
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
|
||||
async function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
|
||||
if (explicitDstId != null && Number.isFinite(explicitDstId)) {
|
||||
const s = db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1).all()[0]
|
||||
const s = (await db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1))[0]
|
||||
if (s) return s
|
||||
}
|
||||
const norm = remoteHost.trim().toLowerCase()
|
||||
return db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
.find((x) => x.host.trim().toLowerCase() === norm)
|
||||
const enabled = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
return enabled.find((x) => x.host.trim().toLowerCase() === norm)
|
||||
}
|
||||
|
||||
const probesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
@@ -271,7 +271,7 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const sid = parseServerId(String((req.params as { id?: string }).id ?? ""))
|
||||
if (sid === null) return reply.status(400).send({ error: "Invalid server id" })
|
||||
|
||||
const server = db.select().from(servers).where(eq(servers.id, sid)).limit(1).all()[0]
|
||||
const server = (await db.select().from(servers).where(eq(servers.id, sid)).limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const body = req.body as {
|
||||
@@ -369,7 +369,7 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
case "bandwidth": {
|
||||
const remote = String(body.bwRemoteAddress ?? "").trim()
|
||||
if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" })
|
||||
const dst = resolveBtestPeer(remote, body.dstServerId)
|
||||
const dst = await resolveBtestPeer(remote, body.dstServerId)
|
||||
if (!dst) {
|
||||
return reply.status(400).send({
|
||||
error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId",
|
||||
|
||||
@@ -80,13 +80,12 @@ function splitGateway(raw: string): { ip: string; name: string } | null {
|
||||
return { ip, name: name || ip }
|
||||
}
|
||||
|
||||
function mapDbRoutes(serverId: number): RecursiveRouteDto[] {
|
||||
const rows = db
|
||||
async function mapDbRoutes(serverId: number): Promise<RecursiveRouteDto[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(recursiveRoutes)
|
||||
.where(eq(recursiveRoutes.serverId, serverId))
|
||||
.orderBy(asc(recursiveRoutes.sortOrder))
|
||||
.all()
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
@@ -118,10 +117,10 @@ function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
|
||||
}
|
||||
|
||||
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
|
||||
db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId)).run()
|
||||
await db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
|
||||
if (routes.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
db.insert(recursiveRoutes).values(
|
||||
await db.insert(recursiveRoutes).values(
|
||||
routes.map((r, i) => ({
|
||||
serverId,
|
||||
sortOrder: i,
|
||||
@@ -138,7 +137,7 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
).run()
|
||||
)
|
||||
}
|
||||
|
||||
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
@@ -146,7 +145,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
const q = req.query as { serverId?: string | number }
|
||||
const serverId = parseDbServerId(q.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
@@ -176,16 +175,16 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
const q = req.query as { serverId?: string | number }
|
||||
const serverId = parseDbServerId(q.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
return reply.send({ routes: mapDbRoutes(serverId) })
|
||||
return reply.send({ routes: await mapDbRoutes(serverId) })
|
||||
})
|
||||
|
||||
app.put("/recursive-routes", async (req, reply) => {
|
||||
const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] }
|
||||
const serverId = parseDbServerId(body.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
await replaceDbRoutes(serverId, body.routes ?? [])
|
||||
return reply.send({ ok: true })
|
||||
@@ -195,10 +194,10 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
const body = req.body as { serverId?: string | number } | undefined
|
||||
const serverId = parseDbServerId(body?.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server: ServerRow | undefined = db
|
||||
const server: ServerRow | undefined = (await db
|
||||
.select().from(servers)
|
||||
.where(eq(servers.id, serverId))
|
||||
.limit(1).all()[0]
|
||||
.limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
@@ -231,7 +230,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
const body = req.body as { serverId?: string | number } | undefined
|
||||
const serverId = parseDbServerId(body?.serverId)
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
@@ -243,7 +242,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
|
||||
}
|
||||
|
||||
const dbRows = mapDbRoutes(serverId)
|
||||
const dbRows = await mapDbRoutes(serverId)
|
||||
for (const route of dbRows) {
|
||||
await client.post("/ip/route", toRouterPayload(route))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function parseJobKey(raw: string | undefined): typeof JOB_KEYS[number] | null {
|
||||
|
||||
const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/scheduler/status", async (_req, reply) => {
|
||||
return reply.send(getSchedulerStatus())
|
||||
return reply.send(await getSchedulerStatus())
|
||||
})
|
||||
|
||||
app.get("/scheduler/runs", async (req, reply) => {
|
||||
@@ -25,12 +25,12 @@ const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
const limit = Math.min(200, Math.max(1, Number.parseInt(String(q.limit ?? "50"), 10) || 50))
|
||||
const offset = Math.max(0, Number.parseInt(String(q.offset ?? "0"), 10) || 0)
|
||||
return reply.send(listSchedulerRuns({ jobKey: jobKey ?? undefined, limit, offset }))
|
||||
return reply.send(await listSchedulerRuns({ jobKey: jobKey ?? undefined, limit, offset }))
|
||||
})
|
||||
|
||||
app.post("/scheduler/refresh", async (_req, reply) => {
|
||||
refreshScheduler()
|
||||
return reply.send({ ok: true, status: getSchedulerStatus() })
|
||||
await refreshScheduler()
|
||||
return reply.send({ ok: true, status: await getSchedulerStatus() })
|
||||
})
|
||||
|
||||
app.post("/scheduler/jobs/:jobKey/run-now", async (req, reply) => {
|
||||
@@ -38,7 +38,7 @@ const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!jobKey) return reply.status(400).send({ error: "Некорректный jobKey" })
|
||||
try {
|
||||
await runSchedulerJobNow(jobKey)
|
||||
return reply.send({ ok: true, status: getSchedulerStatus() })
|
||||
return reply.send({ ok: true, status: await getSchedulerStatus() })
|
||||
} catch (e) {
|
||||
const statusCode = (e as Error & { statusCode?: number }).statusCode
|
||||
if (statusCode === 409) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/servers-api-ping/settings", async (_req, reply) => {
|
||||
const settings = getServersApiPingSettings()
|
||||
const settings = await getServersApiPingSettings()
|
||||
const state = getServersRestPingCollectorState()
|
||||
return reply.send({
|
||||
enabled: settings.enabled,
|
||||
@@ -29,11 +29,11 @@ const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
const intervalSec =
|
||||
body.intervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.intervalSec), 10) || 120)
|
||||
const updated = updateServersApiPingSettings({
|
||||
const updated = await updateServersApiPingSettings({
|
||||
enabled: body.enabled,
|
||||
intervalSec,
|
||||
})
|
||||
refreshScheduler()
|
||||
await refreshScheduler()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
settings: {
|
||||
@@ -49,7 +49,7 @@ const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.post("/servers-api-ping/collect-now", async (_req, reply) => {
|
||||
await collectServersRestPingOnce()
|
||||
scheduleAlertEngineAfterDataCollectors()
|
||||
const row = getServersApiPingSettings()
|
||||
const row = await getServersApiPingSettings()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
lastCollectedAt: row.lastCollectedAt ?? null,
|
||||
|
||||
@@ -77,13 +77,13 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// GET /api/servers
|
||||
app.get("/", async (_req, reply) => {
|
||||
return reply.send(listServersRead())
|
||||
return reply.send(await listServersRead())
|
||||
})
|
||||
|
||||
// GET /api/servers/:id/ros-src-address — IPv4 для src-address в RouterOS (не FQDN)
|
||||
app.get("/:id/ros-src-address", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = getServerRowById(params.id)
|
||||
const server = await getServerRowById(params.id)
|
||||
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
const ipv4 = await resolveRosSrcIpv4(server.host)
|
||||
@@ -93,7 +93,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
// GET /api/servers/:id/wan-runtime — DHCP lease + active default route for HomeRouter WAN uplinks
|
||||
app.get("/:id/wan-runtime", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = getServerReadById(params.id)
|
||||
const server = await getServerReadById(params.id)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const toIp = (raw: string | null | undefined): string | null => {
|
||||
@@ -141,7 +141,9 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(getServerRowById(params.id)!)
|
||||
const row = await getServerRowById(params.id)
|
||||
if (!row) return reply.status(404).send({ error: "Server not found" })
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const isTrue = (v: unknown) => {
|
||||
const s = String(v ?? "").trim().toLowerCase()
|
||||
return s === "true" || s === "yes"
|
||||
@@ -228,13 +230,13 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
// POST /api/servers
|
||||
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
|
||||
return reply.status(201).send(createServer(req.body as ServerCreateRequest))
|
||||
return reply.status(201).send(await createServer(req.body as ServerCreateRequest))
|
||||
})
|
||||
|
||||
// GET /api/servers/:id
|
||||
app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = getServerReadById(params.id)
|
||||
const server = await getServerReadById(params.id)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
return reply.send(server)
|
||||
})
|
||||
@@ -245,25 +247,25 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
{ schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } },
|
||||
async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const existing = getServerReadById(params.id)
|
||||
const existing = await getServerReadById(params.id)
|
||||
if (!existing) return reply.status(404).send({ error: "Server not found" })
|
||||
return reply.send(updateServer(params.id, req.body as ServerUpdateRequest))
|
||||
return reply.send(await updateServer(params.id, req.body as ServerUpdateRequest))
|
||||
},
|
||||
)
|
||||
|
||||
// DELETE /api/servers/:id
|
||||
app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const existing = getServerReadById(params.id)
|
||||
const existing = await getServerReadById(params.id)
|
||||
if (!existing) return reply.status(404).send({ error: "Server not found" })
|
||||
deleteServer(params.id)
|
||||
await deleteServer(params.id)
|
||||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
// POST /api/servers/:id/poll
|
||||
app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const existing = getServerReadById(params.id)
|
||||
const existing = await getServerReadById(params.id)
|
||||
if (!existing) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
@@ -281,9 +283,9 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const query = req.query as SnapshotsQuery
|
||||
const existing = getServerReadById(params.id)
|
||||
const existing = await getServerReadById(params.id)
|
||||
if (!existing) return reply.status(404).send({ error: "Server not found" })
|
||||
return reply.send(listServerSnapshots(params.id, query.limit))
|
||||
return reply.send(await listServerSnapshots(params.id, query.limit))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { count } from "drizzle-orm"
|
||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
||||
import { db } from "../db/index.js"
|
||||
@@ -9,26 +10,26 @@ import {
|
||||
uptimeProbes,
|
||||
uptimeSpeedProbes,
|
||||
} from "../db/schema.js"
|
||||
import { listUsers } from "../modules/users/service/users-service.js"
|
||||
|
||||
async function tableCount(table: typeof servers | typeof filterRules | typeof uptimeProbes | typeof uptimeSpeedProbes | typeof recursiveRoutes): Promise<number> {
|
||||
const rows = await db.select({ n: count() }).from(table)
|
||||
return rows[0]?.n ?? 0
|
||||
}
|
||||
|
||||
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/sidebar-counts", async (_req, reply) => {
|
||||
const [
|
||||
serversTotal,
|
||||
filterRulesTotal,
|
||||
uptimeProbesTotal,
|
||||
uptimeSpeedProbesTotal,
|
||||
recursiveRoutesTotal,
|
||||
certificatesTotal,
|
||||
wireguardTotal,
|
||||
] = await Promise.all([
|
||||
Promise.resolve(db.select().from(servers).all().length),
|
||||
Promise.resolve(db.select().from(filterRules).all().length),
|
||||
Promise.resolve(db.select().from(uptimeProbes).all().length),
|
||||
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
|
||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||
const serversTotal = await tableCount(servers)
|
||||
const filterRulesTotal = await tableCount(filterRules)
|
||||
const uptimeProbesTotal = await tableCount(uptimeProbes)
|
||||
const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes)
|
||||
const recursiveRoutesTotal = await tableCount(recursiveRoutes)
|
||||
const [certRes, wireguardTotal] = await Promise.all([
|
||||
listCertificatesFromServers(),
|
||||
countWireGuardInterfaces().catch(() => 0),
|
||||
])
|
||||
const certificatesTotal = certRes.certificates.length
|
||||
const usersTotal = (await listUsers()).length
|
||||
|
||||
return reply.send({
|
||||
servers: serversTotal,
|
||||
@@ -39,6 +40,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
recursiveRoutes: recursiveRoutesTotal,
|
||||
certificates: certificatesTotal,
|
||||
wireguard: wireguardTotal,
|
||||
users: usersTotal,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get("/system/database/backup", async (_req, reply) => {
|
||||
try {
|
||||
const { filename, buffer } = await exportSystemDatabaseBackup()
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "info",
|
||||
eventType: "system.database.backup",
|
||||
sourceModule: "system",
|
||||
@@ -44,16 +44,16 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post("/system/database/restore", async (req, reply) => {
|
||||
const body = req.body
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) {
|
||||
return reply.status(400).send({ error: "Ожидается тело запроса с файлом SQLite" })
|
||||
return reply.status(400).send({ error: "Ожидается тело запроса с файлом pg_dump (custom, PGDMP)" })
|
||||
}
|
||||
try {
|
||||
await restoreSystemDatabaseBackup(body)
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "warning",
|
||||
eventType: "system.database.restore",
|
||||
sourceModule: "system",
|
||||
title: "Восстановлена база приложения",
|
||||
message: "Данные SQLite заменены из загруженного файла",
|
||||
message: "Данные PostgreSQL заменены из загруженного дампа",
|
||||
entityType: "system_database",
|
||||
entityId: "restore",
|
||||
payload: {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import { env } from "../config.js"
|
||||
import {
|
||||
trafficFlowOverlayRequestSchema,
|
||||
trafficFlowSettingsPatchSchema,
|
||||
} from "@mmapp/contracts/traffic-flow"
|
||||
import {
|
||||
ensureHostKeys,
|
||||
getTrafficFlowSettingsRow,
|
||||
toTrafficFlowSettingsDto,
|
||||
updateTrafficFlowSettings,
|
||||
} from "../services/traffic-flow-settings.js"
|
||||
import {
|
||||
getFlowListenerState,
|
||||
purgeTrafficFlowStore,
|
||||
startTrafficFlowListener,
|
||||
listFlowTalkers,
|
||||
} from "../services/traffic-flow-ingest.js"
|
||||
import {
|
||||
buildFlowAnalytics,
|
||||
getFlowMonthly,
|
||||
listFlowClients,
|
||||
listFlowExporters,
|
||||
safeBuildLiveFlowSample,
|
||||
} from "../services/traffic-flow-analytics.js"
|
||||
import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js"
|
||||
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
||||
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
|
||||
const LIVE_TICK_MS = 2000
|
||||
export const MAX_FLOW_LIVE_SUBSCRIBERS = 4
|
||||
let liveSubscribers = 0
|
||||
|
||||
export function tryAcquireFlowLiveSlot(): boolean {
|
||||
if (liveSubscribers >= MAX_FLOW_LIVE_SUBSCRIBERS) return false
|
||||
liveSubscribers += 1
|
||||
return true
|
||||
}
|
||||
|
||||
export function releaseFlowLiveSlot(): void {
|
||||
liveSubscribers = Math.max(0, liveSubscribers - 1)
|
||||
}
|
||||
|
||||
export function resetFlowLiveSlotsForTests(): void {
|
||||
liveSubscribers = 0
|
||||
}
|
||||
|
||||
function rangeToMinutes(range: string | undefined): number {
|
||||
switch ((range ?? "5m").toLowerCase()) {
|
||||
case "5m": return 5
|
||||
case "15m": return 15
|
||||
case "1h": return 60
|
||||
case "4h": return 240
|
||||
case "24h": return 1440
|
||||
case "30d": return 1440
|
||||
default: return 5
|
||||
}
|
||||
}
|
||||
|
||||
function parseId(raw: unknown): number | undefined {
|
||||
if (raw == null || raw === "") return undefined
|
||||
const n = Number.parseInt(String(raw), 10)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function parseDedup(raw: unknown): boolean {
|
||||
if (raw == null || raw === "") return true
|
||||
const s = String(raw).toLowerCase()
|
||||
return s !== "0" && s !== "false" && s !== "off"
|
||||
}
|
||||
|
||||
function analyticsQuery(req: FastifyRequest) {
|
||||
const q = req.query as {
|
||||
range?: string
|
||||
serverId?: string
|
||||
userId?: string
|
||||
iface?: string
|
||||
dedup?: string
|
||||
excludeMesh?: string
|
||||
excludeOverlay?: string
|
||||
}
|
||||
return {
|
||||
minutes: rangeToMinutes(q.range),
|
||||
serverId: parseId(q.serverId),
|
||||
userId: q.userId?.trim() || undefined,
|
||||
iface: q.iface?.trim() || undefined,
|
||||
dedup: parseDedup(q.dedup),
|
||||
excludeMesh: parseDedup(q.excludeMesh),
|
||||
excludeOverlay: parseDedup(q.excludeOverlay),
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
|
||||
const q = req.query as { range?: string }
|
||||
return reply.send(await listFlowTalkers(rangeToMinutes(q.range)))
|
||||
}
|
||||
|
||||
function requestPublicHost(req: FastifyRequest): string {
|
||||
const forwarded = req.headers["x-forwarded-host"]
|
||||
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
|
||||
return raw || req.hostname || ""
|
||||
}
|
||||
|
||||
async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
|
||||
const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
try {
|
||||
const result = await applyFlowOverlay(parsed.data.serverId, {
|
||||
publicEndpoint: parsed.data.publicEndpoint,
|
||||
requestHost: requestPublicHost(req),
|
||||
})
|
||||
return reply.send(result)
|
||||
} catch (e) {
|
||||
const status = (e as { statusCode?: number }).statusCode ?? 502
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(status).send({ error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
|
||||
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error("aborted"))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error("aborted"))
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/flow/settings", async (_req, reply) => {
|
||||
return reply.send(await toTrafficFlowSettingsDto(getFlowListenerState()))
|
||||
})
|
||||
|
||||
app.put("/traffic/flow/settings", async (req, reply) => {
|
||||
const parsed = trafficFlowSettingsPatchSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
await updateTrafficFlowSettings(parsed.data)
|
||||
await startTrafficFlowListener()
|
||||
return reply.send({ ok: true, settings: await toTrafficFlowSettingsDto(getFlowListenerState()) })
|
||||
})
|
||||
|
||||
app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => {
|
||||
const result = await ensureHostKeys()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
created: result.created,
|
||||
publicKey: result.publicKey,
|
||||
settings: await toTrafficFlowSettingsDto(getFlowListenerState()),
|
||||
})
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/host-files", async (_req, reply) => {
|
||||
const row = await getTrafficFlowSettingsRow()
|
||||
if (!row.hostPrivateKey) await ensureHostKeys()
|
||||
return reply.send({ files: await listTrafficFlowHostFiles() })
|
||||
})
|
||||
|
||||
app.post("/traffic/flow/purge", async (_req, reply) => {
|
||||
try {
|
||||
const result = await purgeTrafficFlowStore()
|
||||
await appendEvent({
|
||||
level: "warning",
|
||||
eventType: "traffic.flow.purge",
|
||||
sourceModule: "traffic",
|
||||
title: "Сброшены данные NetFlow",
|
||||
message: `Удалены сессии ${result.deleted.buckets}, minute ${result.deleted.minuteStats}, daily ${result.deleted.dailyDims}`,
|
||||
entityType: "traffic_flow",
|
||||
entityId: "purge",
|
||||
payload: {
|
||||
buckets: result.deleted.buckets,
|
||||
minuteStats: result.deleted.minuteStats,
|
||||
minuteDims: result.deleted.minuteDims,
|
||||
dailyDims: result.deleted.dailyDims,
|
||||
fileBytesBefore: result.fileBytesBefore,
|
||||
fileBytesAfter: result.fileBytesAfter,
|
||||
vacuumed: result.vacuumed,
|
||||
},
|
||||
})
|
||||
return reply.send(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const status = message.includes("уже выполняется") ? 409 : 500
|
||||
return reply.status(status).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/traffic/flow/overlay", applyOverlayHandler)
|
||||
app.post("/traffic/flow-overlay", applyOverlayHandler)
|
||||
|
||||
app.get("/traffic/flow", sendFlowTalkers)
|
||||
app.get("/traffic/flows", sendFlowTalkers)
|
||||
|
||||
app.get("/traffic/flow/exporters", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
return reply.send(await listFlowExporters(rangeToMinutes(q.range)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/clients", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
return reply.send(await listFlowClients(rangeToMinutes(q.range)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/analytics", async (req, reply) => {
|
||||
return reply.send(await buildFlowAnalytics(analyticsQuery(req)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/map-hops", async (req, reply) => {
|
||||
return reply.send(await buildFlowMapHops(analyticsQuery(req)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/monthly", async (req, reply) => {
|
||||
const q = req.query as { month?: string; serverId?: string }
|
||||
const now = new Date()
|
||||
const month = /^\d{4}-\d{2}$/.test(q.month ?? "")
|
||||
? (q.month as string)
|
||||
: `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`
|
||||
return reply.send(await getFlowMonthly(month, parseId(q.serverId)))
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/live", async (req, reply) => {
|
||||
if (!tryAcquireFlowLiveSlot()) {
|
||||
return reply.status(429).send({ error: "Слишком много live-подписок" })
|
||||
}
|
||||
const query = analyticsQuery(req)
|
||||
const liveQuery = {
|
||||
serverId: query.serverId,
|
||||
userId: query.userId,
|
||||
iface: query.iface,
|
||||
dedup: query.dedup,
|
||||
excludeMesh: query.excludeMesh,
|
||||
excludeOverlay: query.excludeOverlay,
|
||||
}
|
||||
const abort = new AbortController()
|
||||
const onClose = () => abort.abort()
|
||||
req.raw.on("close", onClose)
|
||||
|
||||
reply.hijack()
|
||||
req.raw.setTimeout(0)
|
||||
reply.raw.setTimeout(0)
|
||||
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
|
||||
const allowed = env.CORS_ORIGIN
|
||||
const sseHeaders: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
if (origin && (allowed === "*" || allowed === origin)) {
|
||||
sseHeaders["Access-Control-Allow-Origin"] = origin
|
||||
sseHeaders["Access-Control-Allow-Credentials"] = "true"
|
||||
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
|
||||
sseHeaders.Vary = "Origin"
|
||||
}
|
||||
reply.raw.writeHead(200, sseHeaders)
|
||||
reply.raw.write(":\n\n")
|
||||
|
||||
try {
|
||||
while (!abort.signal.aborted) {
|
||||
const payload = safeBuildLiveFlowSample(liveQuery)
|
||||
writeSse(reply.raw, payload.event, payload.data)
|
||||
await sleep(LIVE_TICK_MS, abort.signal)
|
||||
}
|
||||
} catch {
|
||||
/* abort / disconnect */
|
||||
} finally {
|
||||
releaseFlowLiveSlot()
|
||||
req.raw.off("close", onClose)
|
||||
try {
|
||||
reply.raw.end()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default trafficFlowRoutes
|
||||
+267
-114
@@ -1,3 +1,4 @@
|
||||
import { env } from "../config.js"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
@@ -12,6 +13,19 @@ import {
|
||||
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { getEnabledServerById } from "../services/wireguard-live.js"
|
||||
import {
|
||||
bpsToMbps,
|
||||
buildTrafficFromSamples,
|
||||
isLoopbackName,
|
||||
parseMonitorTraffic,
|
||||
rateBpsFromDelta,
|
||||
} from "../services/traffic-rate.js"
|
||||
import {
|
||||
buildBoundInterfaceTraffic,
|
||||
buildUserTrafficList,
|
||||
} from "../services/traffic-users.js"
|
||||
|
||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||
|
||||
@@ -40,14 +54,16 @@ interface TrafficInterfaceDto {
|
||||
txNow: number
|
||||
}
|
||||
|
||||
function latestSnapshot(serverId: number): SnapshotRow | undefined {
|
||||
return db
|
||||
const LIVE_TICK_MS = 1500
|
||||
const LIVE_ROS_TIMEOUT_MS = 4000
|
||||
|
||||
async function latestSnapshot(serverId: number) {
|
||||
return (await db
|
||||
.select()
|
||||
.from(serverSnapshots)
|
||||
.where(eq(serverSnapshots.serverId, serverId))
|
||||
.orderBy(desc(serverSnapshots.polledAt))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
.limit(1))[0]
|
||||
}
|
||||
|
||||
function rangeToMinutes(range: string | undefined): number {
|
||||
@@ -61,14 +77,6 @@ function rangeToMinutes(range: string | undefined): number {
|
||||
}
|
||||
}
|
||||
|
||||
function toSeries(values: number[], target = 60): number[] {
|
||||
if (values.length === 0) return Array(target).fill(0)
|
||||
if (values.length === target) return values
|
||||
if (values.length > target) return values.slice(values.length - target)
|
||||
const head = Array(target - values.length).fill(values[0] ?? 0)
|
||||
return [...head, ...values]
|
||||
}
|
||||
|
||||
function buildServerTraffic(
|
||||
s: typeof servers.$inferSelect,
|
||||
status: TrafficServerDto["status"],
|
||||
@@ -82,92 +90,139 @@ function buildServerTraffic(
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
}>,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
onlyInterface?: string,
|
||||
): TrafficServerDto {
|
||||
const filteredRows = onlyInterface
|
||||
? rows.filter((r) => r.interfaceName === onlyInterface)
|
||||
: rows
|
||||
|
||||
if (filteredRows.length === 0) {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status,
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotal: 0,
|
||||
txTotal: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array(60).fill(0),
|
||||
txSeries: Array(60).fill(0),
|
||||
}
|
||||
}
|
||||
|
||||
const bySampleTs = new Map<string, { rx: number; tx: number }>()
|
||||
const byIface = new Map<string, typeof filteredRows>()
|
||||
for (const r of filteredRows) {
|
||||
const ts = r.sampledAt
|
||||
const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
|
||||
cur.rx += Math.max(0, r.rxBps) / 1_000_000
|
||||
cur.tx += Math.max(0, r.txBps) / 1_000_000
|
||||
bySampleTs.set(ts, cur)
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
}
|
||||
|
||||
const seriesPoints = [...bySampleTs.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
|
||||
const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
|
||||
const txSeries = toSeries(seriesPoints.map((p) => p.tx))
|
||||
const rxNow = rxSeries[rxSeries.length - 1] ?? 0
|
||||
const txNow = txSeries[txSeries.length - 1] ?? 0
|
||||
const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
|
||||
const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
|
||||
|
||||
let rxBytesDelta = 0
|
||||
let txBytesDelta = 0
|
||||
let sessions = 0
|
||||
for (const arr of byIface.values()) {
|
||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const first = sorted[0]
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (first && last) {
|
||||
const dRx = last.rxBytes - first.rxBytes
|
||||
const dTx = last.txBytes - first.txBytes
|
||||
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
|
||||
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
|
||||
if (last.running && !last.disabled) sessions += 1
|
||||
}
|
||||
}
|
||||
|
||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface)
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status,
|
||||
rxNow,
|
||||
txNow,
|
||||
rxPeak,
|
||||
txPeak,
|
||||
rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
sessions,
|
||||
rxSeries,
|
||||
txSeries,
|
||||
rxNow: built.rxNow,
|
||||
txNow: built.txNow,
|
||||
rxPeak: built.rxPeak,
|
||||
txPeak: built.txPeak,
|
||||
rxTotal: built.rxTotalGiB,
|
||||
txTotal: built.txTotalGiB,
|
||||
sessions: built.sessions,
|
||||
rxSeries: built.rxSeries,
|
||||
txSeries: built.txSeries,
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotStatus(serverId: number): Promise<TrafficServerDto["status"]> {
|
||||
const snap = await latestSnapshot(serverId)
|
||||
return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
}
|
||||
|
||||
function ifaceNowMbps(
|
||||
prev: { rxBytes: number; txBytes: number; sampledAt: string } | undefined,
|
||||
last: { rxBytes: number; txBytes: number; sampledAt: string; rxBps: number; txBps: number },
|
||||
): { rxNow: number; txNow: number } {
|
||||
if (!prev) {
|
||||
return { rxNow: bpsToMbps(last.rxBps), txNow: bpsToMbps(last.txBps) }
|
||||
}
|
||||
const t0 = Date.parse(prev.sampledAt)
|
||||
const t1 = Date.parse(last.sampledAt)
|
||||
const rxBps = rateBpsFromDelta(prev.rxBytes, last.rxBytes, t0, t1)
|
||||
const txBps = rateBpsFromDelta(prev.txBytes, last.txBytes, t0, t1)
|
||||
return {
|
||||
rxNow: bpsToMbps(rxBps ?? last.rxBps),
|
||||
txNow: bpsToMbps(txBps ?? last.txBps),
|
||||
}
|
||||
}
|
||||
|
||||
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
|
||||
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error("aborted"))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error("aborted"))
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function flattenMonitor(raw: unknown): unknown[] {
|
||||
if (Array.isArray(raw)) return raw
|
||||
if (raw != null) return [raw]
|
||||
return []
|
||||
}
|
||||
|
||||
async function listRunningIfaceNames(client: MikrotikClient): Promise<string[]> {
|
||||
const ifaces = await client.get<Array<{ name?: string; running?: string; disabled?: string }>>(
|
||||
"/interface",
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
)
|
||||
return ifaces
|
||||
.filter((i) => (i.running ?? "false") === "true"
|
||||
&& (i.disabled ?? "false") !== "true"
|
||||
&& !isLoopbackName(i.name ?? ""))
|
||||
.map((i) => i.name ?? "")
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function monitorTrafficOnce(
|
||||
client: MikrotikClient,
|
||||
onlyInterface: string | undefined,
|
||||
cache: { names: string[]; joinedFailed: boolean },
|
||||
signal: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
if (onlyInterface) {
|
||||
return client.post(
|
||||
"/interface/monitor-traffic",
|
||||
{ interface: onlyInterface, once: "" },
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
signal,
|
||||
)
|
||||
}
|
||||
if (cache.names.length === 0) {
|
||||
cache.names = await listRunningIfaceNames(client)
|
||||
}
|
||||
if (cache.names.length === 0) return []
|
||||
if (!cache.joinedFailed) {
|
||||
try {
|
||||
return await client.post(
|
||||
"/interface/monitor-traffic",
|
||||
{ interface: cache.names.join(","), once: "" },
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
signal,
|
||||
)
|
||||
} catch {
|
||||
cache.joinedFailed = true
|
||||
}
|
||||
}
|
||||
const chunks = await Promise.all(
|
||||
cache.names.map((name) =>
|
||||
client.post(
|
||||
"/interface/monitor-traffic",
|
||||
{ interface: name, once: "" },
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
signal,
|
||||
).then(flattenMonitor).catch(() => [] as unknown[]),
|
||||
),
|
||||
)
|
||||
return chunks.flat()
|
||||
}
|
||||
|
||||
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/settings", async (_req, reply) => {
|
||||
const settings = getTrafficSettings()
|
||||
const state = getTrafficCollectorState()
|
||||
const settings = await getTrafficSettings()
|
||||
const state = await getTrafficCollectorState()
|
||||
return reply.send({
|
||||
enabled: settings.enabled,
|
||||
intervalSec: settings.intervalSec,
|
||||
@@ -187,12 +242,12 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
const intervalSec = body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 30)
|
||||
const retentionDays = body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14)
|
||||
const updated = updateTrafficSettings({
|
||||
const updated = await updateTrafficSettings({
|
||||
enabled: body.enabled,
|
||||
intervalSec,
|
||||
retentionDays,
|
||||
})
|
||||
refreshScheduler()
|
||||
await refreshScheduler()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
settings: {
|
||||
@@ -210,8 +265,8 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
try {
|
||||
await collectTrafficOnce()
|
||||
scheduleAlertEngineAfterDataCollectors()
|
||||
const updated = getTrafficSettings()
|
||||
appendEvent({
|
||||
const updated = await getTrafficSettings()
|
||||
await appendEvent({
|
||||
level: "info",
|
||||
eventType: "traffic.collect.manual.ok",
|
||||
sourceModule: "traffic",
|
||||
@@ -229,7 +284,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "critical",
|
||||
eventType: "traffic.collect.manual.failed",
|
||||
sourceModule: "traffic",
|
||||
@@ -243,33 +298,91 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/servers", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const data = allServers.map((s): TrafficServerDto => {
|
||||
const snap = latestSnapshot(s.id)
|
||||
const status: TrafficServerDto["status"] =
|
||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
|
||||
const rows = readServerSamplesInRange(s.id, sinceIso)
|
||||
return buildServerTraffic(s, status, rows)
|
||||
})
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const data = await Promise.all(allServers.map(async (s): Promise<TrafficServerDto> => {
|
||||
const rows = await readServerSamplesInRange(s.id, sinceIso)
|
||||
return buildServerTraffic(s, await snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs)
|
||||
}))
|
||||
|
||||
return reply.send({ servers: data })
|
||||
})
|
||||
|
||||
app.get("/traffic/servers/:id/live", async (req, reply) => {
|
||||
const p = req.params as { id?: string | number }
|
||||
const q = req.query as { iface?: string }
|
||||
const server = await getEnabledServerById(p.id ?? "")
|
||||
if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined
|
||||
const abort = new AbortController()
|
||||
const onClose = () => abort.abort()
|
||||
req.raw.on("close", onClose)
|
||||
|
||||
reply.hijack()
|
||||
req.raw.setTimeout(0)
|
||||
reply.raw.setTimeout(0)
|
||||
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
|
||||
const allowed = env.CORS_ORIGIN
|
||||
const sseHeaders: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
if (origin && (allowed === "*" || allowed === origin)) {
|
||||
sseHeaders["Access-Control-Allow-Origin"] = origin
|
||||
sseHeaders["Access-Control-Allow-Credentials"] = "true"
|
||||
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
|
||||
sseHeaders.Vary = "Origin"
|
||||
}
|
||||
reply.raw.writeHead(200, sseHeaders)
|
||||
reply.raw.write(":\n\n")
|
||||
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const cache = { names: onlyInterface ? [onlyInterface] : [] as string[], joinedFailed: false }
|
||||
|
||||
try {
|
||||
while (!abort.signal.aborted) {
|
||||
try {
|
||||
const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal)
|
||||
const sample = parseMonitorTraffic(raw, { onlyInterface })
|
||||
writeSse(reply.raw, "sample", sample)
|
||||
} catch (error) {
|
||||
if (abort.signal.aborted) break
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
writeSse(reply.raw, "error", { error: msg })
|
||||
}
|
||||
await sleep(LIVE_TICK_MS, abort.signal)
|
||||
}
|
||||
} catch {
|
||||
/* abort / disconnect */
|
||||
} finally {
|
||||
req.raw.off("close", onClose)
|
||||
try {
|
||||
reply.raw.end()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
|
||||
const p = req.params as { id?: string | number }
|
||||
const serverId = Number.parseInt(String(p.id ?? ""), 10)
|
||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
|
||||
|
||||
const allRows = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()
|
||||
const allRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
|
||||
const server = allRows[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const sinceIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
||||
const rows = readServerSamplesInRange(serverId, sinceIso)
|
||||
const rows = await readServerSamplesInRange(serverId, sinceIso)
|
||||
const byIface = new Map<string, typeof rows>()
|
||||
for (const r of rows) {
|
||||
if (isLoopbackName(r.interfaceName)) continue
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
@@ -277,12 +390,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
|
||||
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const last = sorted[sorted.length - 1]
|
||||
const prev = sorted[sorted.length - 2]
|
||||
if (!last) {
|
||||
return { name, running: false, disabled: true, rxNow: 0, txNow: 0 }
|
||||
}
|
||||
const now = ifaceNowMbps(prev, last)
|
||||
return {
|
||||
name,
|
||||
running: Boolean(last?.running),
|
||||
disabled: Boolean(last?.disabled),
|
||||
rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000),
|
||||
txNow: Math.round((last?.txBps ?? 0) / 1_000_000),
|
||||
running: Boolean(last.running),
|
||||
disabled: Boolean(last.disabled),
|
||||
rxNow: now.rxNow,
|
||||
txNow: now.txNow,
|
||||
}
|
||||
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
|
||||
|
||||
@@ -294,19 +412,54 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const q = req.query as { range?: string; iface?: string }
|
||||
const serverId = Number.parseInt(String(p.id ?? ""), 10)
|
||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
|
||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const rows = readServerSamplesInRange(server.id, sinceIso)
|
||||
const snap = latestSnapshot(server.id)
|
||||
const status: TrafficServerDto["status"] =
|
||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined)
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||
const rows = await readServerSamplesInRange(server.id, sinceIso)
|
||||
const data = buildServerTraffic(
|
||||
server,
|
||||
await snapshotStatus(server.id),
|
||||
rows,
|
||||
rangeStartMs,
|
||||
rangeEndMs,
|
||||
q.iface && q.iface !== "__all__" ? q.iface : undefined,
|
||||
)
|
||||
return reply.send({ server: data })
|
||||
})
|
||||
|
||||
app.get("/traffic/users", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
return reply.send({ users: await buildUserTrafficList(rangeStartMs, rangeEndMs) })
|
||||
})
|
||||
|
||||
app.get("/traffic/users/:id", async (req, reply) => {
|
||||
const p = req.params as { id?: string }
|
||||
const q = req.query as { range?: string }
|
||||
const id = String(p.id ?? "")
|
||||
if (!id) return reply.status(400).send({ error: "id is required" })
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
const users = await buildUserTrafficList(rangeStartMs, rangeEndMs)
|
||||
const user = users.find((u) => u.id === id)
|
||||
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
|
||||
return reply.send({ user })
|
||||
})
|
||||
|
||||
app.get("/traffic/bound-interfaces", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
return reply.send({ interfaces: await buildBoundInterfaceTraffic(rangeStartMs, rangeEndMs) })
|
||||
})
|
||||
}
|
||||
|
||||
export default trafficRoutes
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ function toSeries(values: number[], target = 40): number[] {
|
||||
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
|
||||
}
|
||||
|
||||
/** Сэмпл ресурсов из SQLite (readResourceSamplesSince). */
|
||||
type ResourceSampleRow = ReturnType<typeof readResourceSamplesSince>[number]
|
||||
/** Сэмпл ресурсов из PostgreSQL (readResourceSamplesSince). */
|
||||
type ResourceSampleRow = Awaited<ReturnType<typeof readResourceSamplesSince>>[number]
|
||||
|
||||
/**
|
||||
* Последний сэмпл по времени может быть offline после сбоя опроса — тогда UI «пустой».
|
||||
@@ -67,7 +67,7 @@ function pickResourceDisplayRow(rows: ResourceSampleRow[], maxGapMs: number): {
|
||||
|
||||
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/uptime/settings", async (_req, reply) => {
|
||||
const s = readUptimeSettings()
|
||||
const s = await readUptimeSettings()
|
||||
return reply.send({
|
||||
enabled: s.enabled,
|
||||
resourcesEnabled: s.resourcesEnabled ?? s.enabled,
|
||||
@@ -82,7 +82,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
lastCollectedAt: s.lastCollectedAt ?? null,
|
||||
lastDurationMs: s.lastDurationMs ?? null,
|
||||
lastError: s.lastError || null,
|
||||
scheduler: getSchedulerStatus(),
|
||||
scheduler: await getSchedulerStatus(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,7 +97,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
speedIntervalSec?: number | string
|
||||
retentionDays?: number | string
|
||||
}
|
||||
const updated = updateUptimeSettings({
|
||||
const updated = await updateUptimeSettings({
|
||||
enabled: body.enabled,
|
||||
resourcesEnabled: body.resourcesEnabled,
|
||||
pingEnabled: body.pingEnabled,
|
||||
@@ -107,7 +107,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
speedIntervalSec: body.speedIntervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.speedIntervalSec), 10) || 60),
|
||||
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
|
||||
})
|
||||
refreshScheduler()
|
||||
await refreshScheduler()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
settings: {
|
||||
@@ -122,7 +122,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||
lastDurationMs: updated.lastDurationMs ?? null,
|
||||
lastError: updated.lastError || null,
|
||||
scheduler: getSchedulerStatus(),
|
||||
scheduler: await getSchedulerStatus(),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -130,7 +130,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.post("/uptime/collect-now", async (_req, reply) => {
|
||||
await collectUptimeOnce()
|
||||
scheduleAlertEngineAfterDataCollectors()
|
||||
const s = readUptimeSettings()
|
||||
const s = await readUptimeSettings()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
lastCollectedAt: s.lastCollectedAt ?? null,
|
||||
@@ -158,7 +158,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.get("/uptime/probes", async (_req, reply) => {
|
||||
const rows = readProbeRows()
|
||||
const rows = await readProbeRows()
|
||||
return reply.send({
|
||||
probes: rows.map((p) => ({
|
||||
id: p.id,
|
||||
@@ -178,7 +178,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.get("/uptime/speed-probes", async (_req, reply) => {
|
||||
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all()
|
||||
const rows = await db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder))
|
||||
return reply.send({
|
||||
probes: rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -204,7 +204,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.get("/uptime/speed-test/runs", async (_req, reply) => {
|
||||
const rows = db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200).all()
|
||||
const rows = await db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200)
|
||||
return reply.send({
|
||||
runs: rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -263,12 +263,12 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
.filter((p) => Number.isFinite(p.srcServerId) && Number.isFinite(p.dstServerId) && p.srcServerId !== p.dstServerId)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const existing = db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes).all()
|
||||
const existing = await db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes)
|
||||
const nextIds = new Set(normalized.map((p) => p.id))
|
||||
|
||||
for (const row of existing) {
|
||||
if (nextIds.has(row.id)) continue
|
||||
db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id)).run()
|
||||
await db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id))
|
||||
}
|
||||
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
@@ -285,15 +285,15 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
sortOrder: i,
|
||||
updatedAt: now,
|
||||
}
|
||||
const updated = db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).run()
|
||||
if ((updated.changes ?? 0) > 0) continue
|
||||
db.insert(uptimeSpeedProbes).values({
|
||||
const updated = await db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).returning({ id: uptimeSpeedProbes.id })
|
||||
if (updated.length > 0) continue
|
||||
await db.insert(uptimeSpeedProbes).values({
|
||||
id: p.id,
|
||||
...patch,
|
||||
createdAt: now,
|
||||
}).run()
|
||||
})
|
||||
}
|
||||
refreshScheduler()
|
||||
await refreshScheduler()
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -326,8 +326,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
: Number.parseInt(String(p.intervalSec), 10),
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
|
||||
replaceProbes(normalized)
|
||||
refreshScheduler()
|
||||
await replaceProbes(normalized)
|
||||
await refreshScheduler()
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -339,7 +339,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (typeof body.showOnDashboard !== "boolean") {
|
||||
return reply.status(400).send({ error: "showOnDashboard boolean required" })
|
||||
}
|
||||
const ok = updateProbeShowOnDashboard(probeId, body.showOnDashboard)
|
||||
const ok = await updateProbeShowOnDashboard(probeId, body.showOnDashboard)
|
||||
if (!ok) return reply.status(404).send({ error: "Probe not found" })
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
@@ -348,7 +348,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const params = req.params as { id?: string | number }
|
||||
const serverId = Number.parseInt(String(params.id ?? ""), 10)
|
||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
|
||||
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!srv) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const rows = await MikrotikClient.fromServer(srv).getInterfaces()
|
||||
@@ -386,8 +386,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "Invalid src/dst server id" })
|
||||
}
|
||||
if (srcId === dstId) return reply.status(400).send({ error: "Source and destination must be different" })
|
||||
const src = db.select().from(servers).where(eq(servers.id, srcId)).limit(1).all()[0]
|
||||
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
|
||||
const src = (await db.select().from(servers).where(eq(servers.id, srcId)).limit(1))[0]
|
||||
const dst = (await db.select().from(servers).where(eq(servers.id, dstId)).limit(1))[0]
|
||||
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const result = await runBandwidthSpeedTest({
|
||||
@@ -404,7 +404,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.send({ ok: true, result })
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
|
||||
recordSpeedTestFailure({
|
||||
await recordSpeedTestFailure({
|
||||
probeId: String(body.probeId ?? "").trim() || undefined,
|
||||
runId: String(body.runId ?? "").trim() || undefined,
|
||||
srcServerId: srcId,
|
||||
@@ -430,7 +430,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const params = req.params as { id?: string | number }
|
||||
const serverId = Number.parseInt(String(params.id ?? ""), 10)
|
||||
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
|
||||
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
const srv = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||
if (!srv) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(srv)
|
||||
@@ -473,10 +473,10 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
|
||||
const allServers = db.select().from(servers).all()
|
||||
const probeRows = readProbeRows()
|
||||
const probeSamples = readProbeSamplesSince(sinceIso)
|
||||
const uptimeCfg = readUptimeSettings()
|
||||
const allServers = await db.select().from(servers)
|
||||
const probeRows = await readProbeRows()
|
||||
const probeSamples = await readProbeSamplesSince(sinceIso)
|
||||
const uptimeCfg = await readUptimeSettings()
|
||||
const intervalSec = Math.max(15, uptimeCfg.intervalSec ?? 300)
|
||||
/** Допустимый разрыв между последней попыткой и последним «хорошим» сэмплом (не показывать данные часовой давности). */
|
||||
const resourceFallbackMaxGapMs = Math.min(2 * 3600 * 1000, Math.max(10 * 60 * 1000, intervalSec * 6 * 1000))
|
||||
@@ -501,8 +501,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
})
|
||||
|
||||
const resources = allServers.map((s) => {
|
||||
const rows = readResourceSamplesSince(sinceIso, s.id)
|
||||
const resources = await Promise.all(allServers.map(async (s) => {
|
||||
const rows = await readResourceSamplesSince(sinceIso, s.id)
|
||||
const { row: pick, hasData } = pickResourceDisplayRow(rows, resourceFallbackMaxGapMs)
|
||||
const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40)
|
||||
return {
|
||||
@@ -518,7 +518,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
boardName: pick?.boardName || "RouterBOARD",
|
||||
temp: undefined as number | undefined,
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
return reply.send({ probes, resources })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import {
|
||||
appUserCreateSchema,
|
||||
appUserIdParamSchema,
|
||||
appUserUpdateSchema,
|
||||
bindingIdParamSchema,
|
||||
interfaceCatalogQuerySchema,
|
||||
userBindingCreateSchema,
|
||||
type AppUserCreate,
|
||||
type AppUserUpdate,
|
||||
type UserBindingCreate,
|
||||
} from "@mmapp/contracts/users"
|
||||
import {
|
||||
addBinding,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
listInterfaceCatalog,
|
||||
listUsers,
|
||||
removeBinding,
|
||||
updateUser,
|
||||
UsersServiceError,
|
||||
} from "../modules/users/service/users-service.js"
|
||||
|
||||
function sendServiceError(reply: { status: (c: number) => { send: (b: unknown) => unknown } }, err: unknown) {
|
||||
if (err instanceof UsersServiceError) {
|
||||
return reply.status(err.status).send({ error: err.message })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const usersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/users", async (_req, reply) => {
|
||||
return reply.send({ users: await listUsers() })
|
||||
})
|
||||
|
||||
app.get("/users/interface-catalog", {
|
||||
schema: { querystring: interfaceCatalogQuerySchema },
|
||||
}, async (req, reply) => {
|
||||
const q = req.query as { serverId: number }
|
||||
try {
|
||||
return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) })
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/users/:id", {
|
||||
schema: { params: appUserIdParamSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id } = req.params as { id: string }
|
||||
const user = await getUserById(id)
|
||||
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
|
||||
return reply.send({ user })
|
||||
})
|
||||
|
||||
app.post("/users", {
|
||||
schema: { body: appUserCreateSchema },
|
||||
}, async (req, reply) => {
|
||||
try {
|
||||
const user = await createUser(req.body as AppUserCreate)
|
||||
return reply.status(201).send({ user })
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/users/:id", {
|
||||
schema: { params: appUserIdParamSchema, body: appUserUpdateSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id } = req.params as { id: string }
|
||||
try {
|
||||
const user = await updateUser(id, req.body as AppUserUpdate)
|
||||
return reply.send({ user })
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/users/:id", {
|
||||
schema: { params: appUserIdParamSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id } = req.params as { id: string }
|
||||
try {
|
||||
await deleteUser(id)
|
||||
return reply.status(204).send()
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/users/:id/bindings", {
|
||||
schema: { params: appUserIdParamSchema, body: userBindingCreateSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id } = req.params as { id: string }
|
||||
try {
|
||||
const binding = await addBinding(id, req.body as UserBindingCreate)
|
||||
return reply.status(201).send({ binding })
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/users/:id/bindings/:bindingId", {
|
||||
schema: { params: bindingIdParamSchema },
|
||||
}, async (req, reply) => {
|
||||
const { id, bindingId } = req.params as { id: string; bindingId: string }
|
||||
try {
|
||||
await removeBinding(id, bindingId)
|
||||
return reply.status(204).send()
|
||||
} catch (err) {
|
||||
return sendServiceError(reply, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default usersRoutes
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
getEnabledServerById,
|
||||
listWireGuardInterfaces,
|
||||
} from "../services/wireguard-live.js"
|
||||
import {
|
||||
putIpAddress,
|
||||
putWireguardInterface,
|
||||
putWireguardPeer,
|
||||
toRosBody,
|
||||
} from "../services/wireguard-ros.js"
|
||||
|
||||
function serverIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
@@ -30,14 +36,6 @@ function rosIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
}
|
||||
|
||||
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v !== undefined && v !== "") out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
|
||||
return toRosBody({
|
||||
interface: p.interfaceName,
|
||||
@@ -99,20 +97,17 @@ async function applyParsedConfig(
|
||||
comment: parsed.interface.comment,
|
||||
disabled: parsed.interface.disabled ? "yes" : undefined,
|
||||
})
|
||||
await client.put("/interface/wireguard", ifaceBody)
|
||||
await putWireguardInterface(client, ifaceBody)
|
||||
|
||||
if (parsed.interface.address) {
|
||||
await client.put("/ip/address", {
|
||||
address: parsed.interface.address,
|
||||
interface: name,
|
||||
})
|
||||
await putIpAddress(client, parsed.interface.address, name)
|
||||
}
|
||||
|
||||
let peersCreated = 0
|
||||
for (const p of parsed.peers) {
|
||||
if (!p.publicKey) continue
|
||||
await client.put(
|
||||
"/interface/wireguard/peers",
|
||||
await putWireguardPeer(
|
||||
client,
|
||||
peerToRosBody({
|
||||
interfaceName: name,
|
||||
publicKey: p.publicKey,
|
||||
@@ -159,35 +154,26 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
const server = await getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.put(
|
||||
"/interface/wireguard",
|
||||
toRosBody({
|
||||
name: body.name,
|
||||
"listen-port": String(body.listenPort),
|
||||
mtu: String(body.mtu),
|
||||
comment: body.comment,
|
||||
"private-key": body.privateKey,
|
||||
disabled: body.disabled ? "yes" : undefined,
|
||||
}),
|
||||
)
|
||||
await putWireguardInterface(client, {
|
||||
name: body.name,
|
||||
"listen-port": String(body.listenPort),
|
||||
mtu: String(body.mtu),
|
||||
comment: body.comment,
|
||||
"private-key": body.privateKey,
|
||||
disabled: body.disabled ? "yes" : undefined,
|
||||
})
|
||||
|
||||
if (body.address) {
|
||||
await client.put("/ip/address", {
|
||||
address: body.address,
|
||||
interface: body.name,
|
||||
})
|
||||
await putIpAddress(client, body.address, body.name)
|
||||
}
|
||||
|
||||
if (body.peer) {
|
||||
await client.put(
|
||||
"/interface/wireguard/peers",
|
||||
peerToRosBody({ ...body.peer, interfaceName: body.name }),
|
||||
)
|
||||
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
|
||||
}
|
||||
|
||||
const list = await listWireGuardInterfaces({
|
||||
@@ -208,7 +194,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
const server = await getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const d = parsed.data
|
||||
@@ -233,7 +219,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
const server = await getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
@@ -251,11 +237,11 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
const server = await getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.put("/interface/wireguard/peers", peerToRosBody(body))
|
||||
await putWireguardPeer(client, peerToRosBody(body))
|
||||
return reply.status(201).send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
@@ -269,7 +255,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
const server = await getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const d = parsed.data
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
@@ -300,7 +286,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
const server = await getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
@@ -329,7 +315,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.send({ dryRun: true, preview })
|
||||
}
|
||||
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
const server = await getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
@@ -347,14 +333,14 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
const server = await getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
|
||||
const list = await listWireGuardInterfaces({
|
||||
serverId: String(server.id),
|
||||
includePrivateKey: body.includePrivateKey === true,
|
||||
})
|
||||
const iface = findIface(list.interfaces, String(server.id), body.interfaceName)
|
||||
const iface = await findIface(list.interfaces, String(server.id), body.interfaceName)
|
||||
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
|
||||
|
||||
if (body.format === "rsc") {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { env } from "../config.js"
|
||||
import { closePool, pool } from "../db/index.js"
|
||||
import { applySqlMigrations } from "../db/migrate.js"
|
||||
import { ensurePartitionsAround } from "../db/partitions.js"
|
||||
import { importSqliteToPostgres } from "../db/sqlite-import.js"
|
||||
|
||||
const sqlitePath = process.argv.find((a) => a.startsWith("--sqlite="))?.slice(9) ?? env.DATABASE_PATH
|
||||
const dryRun = process.argv.includes("--dry-run")
|
||||
const fullHistory = process.argv.includes("--full-history")
|
||||
const strict = process.argv.includes("--strict") || !dryRun
|
||||
|
||||
await applySqlMigrations(pool)
|
||||
await ensurePartitionsAround(pool)
|
||||
const report = await importSqliteToPostgres(pool, sqlitePath, { dryRun, strict, fullHistory })
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
if (!dryRun && report.rejects.length > 0 && strict) {
|
||||
await closePool()
|
||||
process.exit(1)
|
||||
}
|
||||
await closePool()
|
||||
@@ -85,15 +85,11 @@ async function deleteTxtRecord(token: string, zoneId: string, recordId: string):
|
||||
await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
function parseWanUplinks(raw: string | null | undefined): WanUplinkRow[] {
|
||||
if (!raw?.trim()) return []
|
||||
try {
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(data)) return []
|
||||
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
function parseWanUplinks(raw: unknown): WanUplinkRow[] {
|
||||
const data = Array.isArray(raw) ? raw : typeof raw === "string" && raw.trim()
|
||||
? (() => { try { const p = JSON.parse(raw) as unknown; return Array.isArray(p) ? p : [] } catch { return [] } })()
|
||||
: []
|
||||
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
|
||||
}
|
||||
|
||||
function normalizeIpv4(raw: string | undefined): string | null {
|
||||
@@ -217,10 +213,10 @@ async function sleep(ms: number) {
|
||||
}
|
||||
|
||||
async function getOrCreateAccountKey(): Promise<Buffer> {
|
||||
const existing = getAcmeAccountPrivateKey()
|
||||
const existing = await getAcmeAccountPrivateKey()
|
||||
if (existing) return Buffer.from(existing)
|
||||
const key = await acme.crypto.createPrivateKey()
|
||||
saveAcmeAccountPrivateKey(key.toString("utf8"))
|
||||
await saveAcmeAccountPrivateKey(key.toString("utf8"))
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -247,8 +243,8 @@ export async function issueCertificateWithCloudflareDns(params: {
|
||||
trustStore: string[]
|
||||
onStep?: (step: string) => void
|
||||
}): Promise<void> {
|
||||
const settings = getAcmeSettingsPublic()
|
||||
const token = getAcmeCloudflareToken()
|
||||
const settings = await getAcmeSettingsPublic()
|
||||
const token = await getAcmeCloudflareToken()
|
||||
if (!token) throw new Error("Не настроен Cloudflare API token")
|
||||
|
||||
const domains = [...new Set(params.domainNames.map((d) => d.trim().toLowerCase()).filter(Boolean))]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Внутренние хуки: после записи сигналов в SQLite (коллекторы) — отложенный прогон `alert_engine`.
|
||||
* Внутренние хуки: после записи сигналов в PostgreSQL (коллекторы) — отложенный прогон `alert_engine`.
|
||||
* Не импортирует `scheduler.ts` (избегаем цикла); runner задаётся через `wireAlertEngineRunner`.
|
||||
*
|
||||
* Любой код, пишущий таблицы для `buildSignalSnapshot`, после успешного коммита должен вызывать
|
||||
@@ -15,7 +15,7 @@ let alertAfterCollectChain: Promise<void> = Promise.resolve()
|
||||
|
||||
let alertEngineRunner: (() => Promise<void>) | null = null
|
||||
|
||||
/** Задать runner один раз при старте приложения (обычно `() => executeSchedulerJob("alert_engine")`). */
|
||||
/** Задать runner один раз при старте приложения (обычно `() => await executeSchedulerJob("alert_engine")`). */
|
||||
export function wireAlertEngineRunner(run: () => Promise<void>): void {
|
||||
alertEngineRunner = run
|
||||
}
|
||||
@@ -39,7 +39,7 @@ async function runAlertEngineWhenIdle(): Promise<void> {
|
||||
/** После успешной записи сигналов в БД коллекторами (не из `alert_engine`). */
|
||||
export function scheduleAlertEngineAfterDataCollectors(): void {
|
||||
if (alertAfterCollectTimer != null) clearTimeout(alertAfterCollectTimer)
|
||||
alertAfterCollectTimer = setTimeout(() => {
|
||||
alertAfterCollectTimer = setTimeout(async () => {
|
||||
alertAfterCollectTimer = null
|
||||
alertAfterCollectChain = alertAfterCollectChain
|
||||
.then(() => runAlertEngineWhenIdle())
|
||||
|
||||
@@ -4,11 +4,10 @@ import { db } from "../../db/index.js"
|
||||
import { alertEngineConfirmPending } from "../../db/schema.js"
|
||||
import type { ApiAlertRule } from "../alerts-service.js"
|
||||
import { computeStabilityReadyMap, deleteConfirmPending } from "./confirm-stability.js"
|
||||
import { withPgOrSkip } from "../../test/pg.js"
|
||||
|
||||
const ruleId = "test-confirm-stability-recovery-bypass"
|
||||
|
||||
deleteConfirmPending(ruleId)
|
||||
|
||||
const baseRule: ApiAlertRule = {
|
||||
id: ruleId,
|
||||
name: "Server transition",
|
||||
@@ -46,25 +45,31 @@ const recoveryHit = {
|
||||
payloadHash: "recovery-1",
|
||||
}
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
console.log("alert-engine confirm-stability tests skip")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await deleteConfirmPending(ruleId)
|
||||
|
||||
{
|
||||
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]]))
|
||||
const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]]))
|
||||
assert.equal(m.get(ruleId), false, "проблемный переход должен ждать confirmStabilitySec")
|
||||
}
|
||||
|
||||
{
|
||||
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]]))
|
||||
const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]]))
|
||||
assert.equal(m.get(ruleId), true, "восстановление должно отправляться без задержки")
|
||||
}
|
||||
|
||||
{
|
||||
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, null]]))
|
||||
const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, null]]))
|
||||
assert.equal(m.get(ruleId), false, "без hit отправка не готова")
|
||||
const left = db
|
||||
const left = (await db
|
||||
.select()
|
||||
.from(alertEngineConfirmPending)
|
||||
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
.limit(1))[0]
|
||||
assert.equal(left, undefined, "pending должен очищаться после отсутствия hit")
|
||||
}
|
||||
|
||||
|
||||
@@ -4,46 +4,44 @@ import { alertEngineConfirmPending } from "../../db/schema.js"
|
||||
import type { ApiAlertRule } from "../alerts-service.js"
|
||||
import type { RuleEvalHit } from "./types.js"
|
||||
|
||||
export function deleteConfirmPending(ruleId: string) {
|
||||
db.delete(alertEngineConfirmPending).where(eq(alertEngineConfirmPending.ruleId, ruleId)).run()
|
||||
export async function deleteConfirmPending(ruleId: string) {
|
||||
await db.delete(alertEngineConfirmPending).where(eq(alertEngineConfirmPending.ruleId, ruleId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет состояние ожидания стабильности и возвращает, можно ли отправлять уведомление в этом тике.
|
||||
* При отсутствии срабатывания (`hit == null`) ожидание сбрасывается.
|
||||
*/
|
||||
export function stabilityReadyForSend(
|
||||
export async function stabilityReadyForSend(
|
||||
ruleId: string,
|
||||
hit: RuleEvalHit | null,
|
||||
delaySec: number | null | undefined,
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
if (!hit) {
|
||||
deleteConfirmPending(ruleId)
|
||||
await deleteConfirmPending(ruleId)
|
||||
return false
|
||||
}
|
||||
const sec = delaySec != null && delaySec > 0 ? Math.floor(delaySec) : 0
|
||||
if (sec <= 0) {
|
||||
deleteConfirmPending(ruleId)
|
||||
await deleteConfirmPending(ruleId)
|
||||
return true
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const nowIso = new Date().toISOString()
|
||||
const existing = db
|
||||
const existing = (await db
|
||||
.select()
|
||||
.from(alertEngineConfirmPending)
|
||||
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
.limit(1))[0]
|
||||
|
||||
if (!existing || existing.payloadHash !== hit.payloadHash) {
|
||||
if (existing) {
|
||||
db.update(alertEngineConfirmPending)
|
||||
await db.update(alertEngineConfirmPending)
|
||||
.set({ payloadHash: hit.payloadHash, sinceAt: nowIso })
|
||||
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(alertEngineConfirmPending).values({ ruleId, payloadHash: hit.payloadHash, sinceAt: nowIso }).run()
|
||||
await db.insert(alertEngineConfirmPending).values({ ruleId, payloadHash: hit.payloadHash, sinceAt: nowIso })
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -54,18 +52,18 @@ export function stabilityReadyForSend(
|
||||
}
|
||||
|
||||
/** После успешной отправки — сбросить таймер стабильности для следующего цикла. */
|
||||
export function clearConfirmPendingAfterSend(ruleIds: string[]) {
|
||||
for (const id of ruleIds) deleteConfirmPending(id)
|
||||
export async function clearConfirmPendingAfterSend(ruleIds: string[]) {
|
||||
for (const id of ruleIds) await deleteConfirmPending(id)
|
||||
}
|
||||
|
||||
export function computeStabilityReadyMap(
|
||||
export async function computeStabilityReadyMap(
|
||||
rules: ApiAlertRule[],
|
||||
hitByRule: Map<string, RuleEvalHit | null>,
|
||||
): Map<string, boolean> {
|
||||
): Promise<Map<string, boolean>> {
|
||||
const m = new Map<string, boolean>()
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled) {
|
||||
deleteConfirmPending(rule.id)
|
||||
await deleteConfirmPending(rule.id)
|
||||
m.set(rule.id, false)
|
||||
continue
|
||||
}
|
||||
@@ -76,7 +74,7 @@ export function computeStabilityReadyMap(
|
||||
? rule.recoveryStabilitySec
|
||||
: 0
|
||||
: rule.confirmStabilitySec
|
||||
m.set(rule.id, stabilityReadyForSend(rule.id, hit, delaySec))
|
||||
m.set(rule.id, await stabilityReadyForSend(rule.id, hit, delaySec))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -73,20 +73,20 @@ export function buildRuleDiagnostics(
|
||||
return out
|
||||
}
|
||||
|
||||
export function computeDecisions(args: {
|
||||
export async function computeDecisions(args: {
|
||||
rules: ApiAlertRule[]
|
||||
groups: ApiAlertGroup[]
|
||||
hitByRule: Map<string, RuleEvalHit | null>
|
||||
state: EngineStateMap
|
||||
canSend: boolean
|
||||
}): {
|
||||
}): Promise<{
|
||||
stabilityReady: Map<string, boolean>
|
||||
standalone: StandaloneDecision[]
|
||||
grouped: GroupDecision[]
|
||||
ruleDiag: AlertEngineRuleDiag[]
|
||||
} {
|
||||
}> {
|
||||
const { rules, groups, hitByRule, state, canSend } = args
|
||||
const stabilityReady = computeStabilityReadyMap(rules, hitByRule)
|
||||
const stabilityReady = await computeStabilityReadyMap(rules, hitByRule)
|
||||
const ruleDiag = buildRuleDiagnostics(rules, hitByRule, stabilityReady, state, canSend)
|
||||
const standalone: StandaloneDecision[] = []
|
||||
const grouped: GroupDecision[] = []
|
||||
|
||||
@@ -17,7 +17,9 @@ export type AlertOutboxPayload = {
|
||||
}
|
||||
}
|
||||
|
||||
function safeParsePayload(raw: string): AlertOutboxPayload | null {
|
||||
function safeParsePayload(raw: unknown): AlertOutboxPayload | null {
|
||||
if (raw && typeof raw === "object") return raw as AlertOutboxPayload
|
||||
if (typeof raw !== "string") return null
|
||||
try {
|
||||
return JSON.parse(raw) as AlertOutboxPayload
|
||||
} catch {
|
||||
@@ -25,21 +27,20 @@ function safeParsePayload(raw: string): AlertOutboxPayload | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function enqueueTelegramOutbox(input: {
|
||||
export async function enqueueTelegramOutbox(input: {
|
||||
id: string
|
||||
dedupeKey: string
|
||||
payload: AlertOutboxPayload
|
||||
maxRetries?: number
|
||||
}): boolean {
|
||||
const existing = db
|
||||
}): Promise<boolean> {
|
||||
const existing = (await db
|
||||
.select()
|
||||
.from(alertOutbox)
|
||||
.where(eq(alertOutbox.dedupeKey, input.dedupeKey))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
.limit(1))[0]
|
||||
if (existing && (existing.status === "pending" || existing.status === "sent")) return false
|
||||
const nowIso = new Date().toISOString()
|
||||
db.insert(alertOutbox)
|
||||
await db.insert(alertOutbox)
|
||||
.values({
|
||||
id: input.id,
|
||||
dedupeKey: input.dedupeKey,
|
||||
@@ -48,22 +49,20 @@ export function enqueueTelegramOutbox(input: {
|
||||
retryCount: 0,
|
||||
maxRetries: Math.max(1, Math.floor(input.maxRetries ?? 3)),
|
||||
nextAttemptAt: nowIso,
|
||||
payloadJson: JSON.stringify(input.payload),
|
||||
payloadJson: input.payload,
|
||||
})
|
||||
.run()
|
||||
return true
|
||||
}
|
||||
|
||||
export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; failed: number; errors: string[] }> {
|
||||
return (async () => {
|
||||
const nowIso = new Date().toISOString()
|
||||
const rows = db
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertOutbox)
|
||||
.where(and(eq(alertOutbox.status, "pending"), lte(alertOutbox.nextAttemptAt, nowIso)))
|
||||
.orderBy(asc(alertOutbox.createdAt))
|
||||
.limit(Math.max(1, limit))
|
||||
.all()
|
||||
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
@@ -72,27 +71,25 @@ export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; faile
|
||||
for (const row of rows) {
|
||||
const payload = safeParsePayload(row.payloadJson)
|
||||
if (!payload) {
|
||||
db.update(alertOutbox)
|
||||
await db.update(alertOutbox)
|
||||
.set({
|
||||
status: "failed",
|
||||
lastError: "invalid payload_json",
|
||||
})
|
||||
.where(eq(alertOutbox.id, row.id))
|
||||
.run()
|
||||
failed += 1
|
||||
continue
|
||||
}
|
||||
const send = await sendTelegramAlertMessage({ text: payload.text, chatId: payload.chatId })
|
||||
if (send.ok) {
|
||||
db.update(alertOutbox)
|
||||
await db.update(alertOutbox)
|
||||
.set({
|
||||
status: "sent",
|
||||
sentAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
})
|
||||
.where(eq(alertOutbox.id, row.id))
|
||||
.run()
|
||||
appendAlertHistory({
|
||||
await appendAlertHistory({
|
||||
id: payload.history.id,
|
||||
ruleId: payload.history.ruleId ?? null,
|
||||
groupId: payload.history.groupId ?? null,
|
||||
@@ -107,7 +104,7 @@ export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; faile
|
||||
}
|
||||
const nextRetry = row.retryCount + 1
|
||||
const exhausted = nextRetry >= row.maxRetries
|
||||
db.update(alertOutbox)
|
||||
await db.update(alertOutbox)
|
||||
.set({
|
||||
retryCount: nextRetry,
|
||||
status: exhausted ? "failed" : "pending",
|
||||
@@ -115,9 +112,8 @@ export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; faile
|
||||
lastError: send.error,
|
||||
})
|
||||
.where(eq(alertOutbox.id, row.id))
|
||||
.run()
|
||||
if (exhausted) {
|
||||
appendAlertHistory({
|
||||
await appendAlertHistory({
|
||||
id: payload.history.id,
|
||||
ruleId: payload.history.ruleId ?? null,
|
||||
groupId: payload.history.groupId ?? null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../../db/index.js"
|
||||
import { parseJsonObject } from "../../db/json.js"
|
||||
import { alertEnginePrevLive } from "../../db/schema.js"
|
||||
|
||||
export type PrevLiveKind = "gre" | "bgp"
|
||||
@@ -7,41 +8,32 @@ export type PrevLiveKind = "gre" | "bgp"
|
||||
/** Предыдущие строковые состояния (GRE: up|down|degraded; BGP: state как с роутера). */
|
||||
export type PrevLiveStringMap = Record<string, string>
|
||||
|
||||
function safeParseMap(json: string | null | undefined): PrevLiveStringMap {
|
||||
if (!json || json.trim() === "") return {}
|
||||
try {
|
||||
const v = JSON.parse(json) as unknown
|
||||
if (v == null || typeof v !== "object" || Array.isArray(v)) return {}
|
||||
return v as PrevLiveStringMap
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
function safeParseMap(json: unknown): PrevLiveStringMap {
|
||||
return parseJsonObject(json) as PrevLiveStringMap
|
||||
}
|
||||
|
||||
export function loadPrevLiveMap(kind: PrevLiveKind): PrevLiveStringMap {
|
||||
const row = db
|
||||
export async function loadPrevLiveMap(kind: PrevLiveKind): Promise<PrevLiveStringMap> {
|
||||
const row = (await db
|
||||
.select()
|
||||
.from(alertEnginePrevLive)
|
||||
.where(eq(alertEnginePrevLive.kind, kind))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
.limit(1))[0]
|
||||
return safeParseMap(row?.payloadJson)
|
||||
}
|
||||
|
||||
export function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
|
||||
const payloadJson = JSON.stringify(map)
|
||||
const existing = db
|
||||
export async function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
|
||||
const payloadJson = map
|
||||
const existing = (await db
|
||||
.select()
|
||||
.from(alertEnginePrevLive)
|
||||
.where(eq(alertEnginePrevLive.kind, kind))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
.limit(1))[0]
|
||||
if (existing) {
|
||||
db.update(alertEnginePrevLive)
|
||||
if (existing.payloadJson === payloadJson) return
|
||||
await db.update(alertEnginePrevLive)
|
||||
.set({ payloadJson, updatedAt: new Date().toISOString() })
|
||||
.where(eq(alertEnginePrevLive.kind, kind))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
|
||||
return
|
||||
}
|
||||
await db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() })
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const SNAPSHOT_SOURCE_WAIT_MS = 30_000
|
||||
const SNAPSHOT_SOURCE_POLL_MS = 20
|
||||
|
||||
/**
|
||||
* Не строить снимок, пока коллекторы пишут в SQLite — иначе гонка с `alert_engine` по таймеру
|
||||
* Не строить снимок, пока коллекторы пишут в PostgreSQL — иначе гонка с `alert_engine` по таймеру
|
||||
* (в т.ч. слот планировщика занят до первого `await` в коллекторе, когда внутренний `collecting` ещё false).
|
||||
*/
|
||||
async function awaitSnapshotSourcesIdle(): Promise<void> {
|
||||
@@ -52,8 +52,8 @@ function newOutboxId(): string {
|
||||
return `ao-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function loadEngineStateMap(): Map<string, { lastFiredAt: string; lastPayloadHash: string | null }> {
|
||||
const rows = db.select().from(alertEngineState).all()
|
||||
async function loadEngineStateMap(): Promise<Map<string, { lastFiredAt: string; lastPayloadHash: string | null }>> {
|
||||
const rows = await db.select().from(alertEngineState)
|
||||
const m = new Map<string, { lastFiredAt: string; lastPayloadHash: string | null }>()
|
||||
for (const r of rows) {
|
||||
m.set(r.scopeKey, { lastFiredAt: r.lastFiredAt, lastPayloadHash: r.lastPayloadHash ?? null })
|
||||
@@ -61,15 +61,14 @@ function loadEngineStateMap(): Map<string, { lastFiredAt: string; lastPayloadHas
|
||||
return m
|
||||
}
|
||||
|
||||
function upsertEngineState(scopeKey: string, lastFiredAt: string, lastPayloadHash: string | null) {
|
||||
const existing = db.select().from(alertEngineState).where(eq(alertEngineState.scopeKey, scopeKey)).limit(1).all()[0]
|
||||
async function upsertEngineState(scopeKey: string, lastFiredAt: string, lastPayloadHash: string | null) {
|
||||
const existing = (await db.select().from(alertEngineState).where(eq(alertEngineState.scopeKey, scopeKey)).limit(1))[0]
|
||||
if (existing) {
|
||||
db.update(alertEngineState)
|
||||
await db.update(alertEngineState)
|
||||
.set({ lastFiredAt, lastPayloadHash })
|
||||
.where(eq(alertEngineState.scopeKey, scopeKey))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(alertEngineState).values({ scopeKey, lastFiredAt, lastPayloadHash }).run()
|
||||
await db.insert(alertEngineState).values({ scopeKey, lastFiredAt, lastPayloadHash })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,16 +89,16 @@ function ruleScopeKey(ruleId: string, transition?: RuleEvalHit["transition"]): s
|
||||
export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
|
||||
const errors: string[] = []
|
||||
const sampledAt = new Date().toISOString()
|
||||
const latestSourceFinishedAt = getLatestSourceFinishedAt()
|
||||
const prevSourceFinishedAt = getSourceWatermark()
|
||||
const latestSourceFinishedAt = await getLatestSourceFinishedAt()
|
||||
const prevSourceFinishedAt = await getSourceWatermark()
|
||||
const hasNewSources =
|
||||
latestSourceFinishedAt != null &&
|
||||
(prevSourceFinishedAt == null || latestSourceFinishedAt > prevSourceFinishedAt)
|
||||
|
||||
const rules = listAlertRules()
|
||||
const groups = listAlertGroups()
|
||||
const state = loadEngineStateMap()
|
||||
const pub = getTelegramPublic()
|
||||
const rules = await listAlertRules()
|
||||
const groups = await listAlertGroups()
|
||||
const state = await loadEngineStateMap()
|
||||
const pub = await getTelegramPublic()
|
||||
const canSend = pub.tokenConfigured && Boolean(pub.chatId?.trim())
|
||||
let standaloneFires = 0
|
||||
let groupFires = 0
|
||||
@@ -107,10 +106,10 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
|
||||
let ruleDiag: AlertEngineRunResult["ruleDiag"] = []
|
||||
if (hasNewSources) {
|
||||
await awaitSnapshotSourcesIdle()
|
||||
const snap = ingestAlertSignals()
|
||||
const snap = await ingestAlertSignals()
|
||||
const matched = matchRules(rules, snap)
|
||||
errors.push(...matched.errors)
|
||||
const { standalone, grouped, ruleDiag: diag } = computeDecisions({
|
||||
const { standalone, grouped, ruleDiag: diag } = await computeDecisions({
|
||||
rules,
|
||||
groups,
|
||||
hitByRule: matched.hitByRule,
|
||||
@@ -123,7 +122,7 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
|
||||
if (!canSend) break
|
||||
const firedAt = new Date().toISOString()
|
||||
const key = ruleScopeKey(d.rule.id, d.hit.transition)
|
||||
const queued = enqueueTelegramOutbox({
|
||||
const queued = await enqueueTelegramOutbox({
|
||||
id: newOutboxId(),
|
||||
dedupeKey: `${key}:${d.hit.payloadHash}`,
|
||||
payload: {
|
||||
@@ -142,8 +141,8 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
|
||||
})
|
||||
if (!queued) continue
|
||||
standaloneFires += 1
|
||||
clearConfirmPendingAfterSend([d.rule.id])
|
||||
upsertEngineState(key, firedAt, d.hit.payloadHash)
|
||||
await clearConfirmPendingAfterSend([d.rule.id])
|
||||
await upsertEngineState(key, firedAt, d.hit.payloadHash)
|
||||
state.set(key, { lastFiredAt: firedAt, lastPayloadHash: d.hit.payloadHash })
|
||||
}
|
||||
|
||||
@@ -155,7 +154,7 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
|
||||
const body = `Группа «${d.group.name}» (${d.group.combineMode === "any" ? "ANY" : "ALL"})\n\n${lines.join("\n")}`
|
||||
const hash = d.hits.map((h) => h.payloadHash).sort().join("|")
|
||||
const key = `group:${d.group.id}`
|
||||
const queued = enqueueTelegramOutbox({
|
||||
const queued = await enqueueTelegramOutbox({
|
||||
id: newOutboxId(),
|
||||
dedupeKey: `${key}:${hash}`,
|
||||
payload: {
|
||||
@@ -173,18 +172,18 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
|
||||
})
|
||||
if (!queued) continue
|
||||
groupFires += 1
|
||||
upsertEngineState(key, firedAt, hash)
|
||||
await upsertEngineState(key, firedAt, hash)
|
||||
state.set(key, { lastFiredAt: firedAt, lastPayloadHash: hash })
|
||||
clearConfirmPendingAfterSend(d.members.map((r) => r.id))
|
||||
await clearConfirmPendingAfterSend(d.members.map((r) => r.id))
|
||||
}
|
||||
updateSourceWatermark(latestSourceFinishedAt)
|
||||
await updateSourceWatermark(latestSourceFinishedAt)
|
||||
}
|
||||
|
||||
const outbox = await dispatchPendingOutbox()
|
||||
errors.push(...outbox.errors)
|
||||
|
||||
if (standaloneFires > 0 || groupFires > 0) {
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "info",
|
||||
eventType: "alerts.engine.fired",
|
||||
sourceModule: "alerts",
|
||||
@@ -197,7 +196,7 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
|
||||
})
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "warning",
|
||||
eventType: "alerts.engine.errors",
|
||||
sourceModule: "alerts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js"
|
||||
|
||||
/** Единая точка входа для подготовки сигналов к rule matching. */
|
||||
export function ingestAlertSignals() {
|
||||
return buildSignalSnapshotFromCollectors()
|
||||
export async function ingestAlertSignals() {
|
||||
return await buildSignalSnapshotFromCollectors()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sqliteDatabase } from "../../db/index.js"
|
||||
import { dbAll } from "../../db/index.js"
|
||||
import type {
|
||||
GreBgpSnapshotRunSnapshot,
|
||||
PingRunSnapshot,
|
||||
@@ -20,25 +20,23 @@ function normalizeNameKey(v: string): string {
|
||||
return v.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function safeParseRunSnapshot(raw: string | null): SchedulerRunSnapshot | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as SchedulerRunSnapshot
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
function safeParseRunSnapshot(raw: unknown): SchedulerRunSnapshot | null {
|
||||
const obj = typeof raw === "string" ? (() => {
|
||||
try { return JSON.parse(raw) as unknown } catch { return null }
|
||||
})() : raw
|
||||
if (!obj || typeof obj !== "object") return null
|
||||
return obj as SchedulerRunSnapshot
|
||||
}
|
||||
|
||||
function readLatestOkRunSnapshots(jobKey: string, limit = 32): SchedulerRunSnapshot[] {
|
||||
const rows = sqliteDatabase
|
||||
.prepare(
|
||||
`SELECT result_json AS resultJson
|
||||
FROM scheduler_runs
|
||||
WHERE job_key = ? AND status = 'ok' AND result_json IS NOT NULL
|
||||
ORDER BY finished_at DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(jobKey, limit) as { resultJson: string | null }[]
|
||||
async function readLatestOkRunSnapshots(jobKey: string, limit = 32): Promise<SchedulerRunSnapshot[]> {
|
||||
const rows = await dbAll<{ resultJson: unknown }>(
|
||||
`SELECT result_json AS "resultJson"
|
||||
FROM scheduler_runs
|
||||
WHERE job_key = ? AND status = 'ok' AND result_json IS NOT NULL
|
||||
ORDER BY finished_at DESC
|
||||
LIMIT ?`,
|
||||
[jobKey, limit],
|
||||
)
|
||||
const out: SchedulerRunSnapshot[] = []
|
||||
for (const row of rows) {
|
||||
const parsed = safeParseRunSnapshot(row.resultJson)
|
||||
@@ -227,13 +225,13 @@ function collectBgpSignalsFromRuns(runs: GreBgpSnapshotRunSnapshot[]): BgpPeerSi
|
||||
}
|
||||
|
||||
/** Собирает сигналы alert_engine напрямую из результатов collector jobs (`scheduler_runs.result_json`). */
|
||||
export function buildSignalSnapshotFromCollectors(): SignalSnapshot {
|
||||
export async function buildSignalSnapshotFromCollectors(): Promise<SignalSnapshot> {
|
||||
const sampledAt = new Date().toISOString()
|
||||
const resourceRuns = readLatestOkRunSnapshots("uptime_resources", 16) as ResourcesRunSnapshot[]
|
||||
const restRuns = readLatestOkRunSnapshots("servers_rest_ping", 16) as ServersRestPingRunSnapshot[]
|
||||
const pingRuns = readLatestOkRunSnapshots("uptime_ping", 24) as PingRunSnapshot[]
|
||||
const trafficRuns = readLatestOkRunSnapshots("traffic", 8) as TrafficRunSnapshot[]
|
||||
const greBgpRuns = readLatestOkRunSnapshots("gre_bgp", 8) as GreBgpSnapshotRunSnapshot[]
|
||||
const resourceRuns = await readLatestOkRunSnapshots("uptime_resources", 16) as ResourcesRunSnapshot[]
|
||||
const restRuns = await readLatestOkRunSnapshots("servers_rest_ping", 16) as ServersRestPingRunSnapshot[]
|
||||
const pingRuns = await readLatestOkRunSnapshots("uptime_ping", 24) as PingRunSnapshot[]
|
||||
const trafficRuns = await readLatestOkRunSnapshots("traffic", 8) as TrafficRunSnapshot[]
|
||||
const greBgpRuns = await readLatestOkRunSnapshots("gre_bgp", 8) as GreBgpSnapshotRunSnapshot[]
|
||||
|
||||
const resourceByKey = collectServerSignalsFromResourceRuns(resourceRuns)
|
||||
const restByKey = collectServerSignalsFromRestRuns(restRuns)
|
||||
|
||||
@@ -2,6 +2,6 @@ import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js"
|
||||
import type { SignalSnapshot } from "./types.js"
|
||||
|
||||
/** Legacy-совместимость: единый источник сигналов — collector snapshots из scheduler_runs. */
|
||||
export function buildSignalSnapshot(): SignalSnapshot {
|
||||
return buildSignalSnapshotFromCollectors()
|
||||
export async function buildSignalSnapshot(): Promise<SignalSnapshot> {
|
||||
return await buildSignalSnapshotFromCollectors()
|
||||
}
|
||||
|
||||
@@ -11,44 +11,40 @@ const SOURCE_JOBS = [
|
||||
"gre_bgp",
|
||||
] as const
|
||||
|
||||
export function getLatestSourceFinishedAt(): string | null {
|
||||
const row = db
|
||||
export async function getLatestSourceFinishedAt(): Promise<string | null> {
|
||||
const row = (await db
|
||||
.select({ finishedAt: schedulerRuns.finishedAt })
|
||||
.from(schedulerRuns)
|
||||
.where(inArray(schedulerRuns.jobKey, [...SOURCE_JOBS]))
|
||||
.orderBy(desc(schedulerRuns.finishedAt))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
.limit(1))[0]
|
||||
return row?.finishedAt ?? null
|
||||
}
|
||||
|
||||
export function getSourceWatermark(): string | null {
|
||||
const row = db.select().from(alertEngineCursor).where(eq(alertEngineCursor.id, 1)).limit(1).all()[0]
|
||||
export async function getSourceWatermark(): Promise<string | null> {
|
||||
const row = (await db.select().from(alertEngineCursor).where(eq(alertEngineCursor.id, 1)).limit(1))[0]
|
||||
return row?.lastSourceFinishedAt ?? null
|
||||
}
|
||||
|
||||
export function updateSourceWatermark(lastSourceFinishedAt: string | null): void {
|
||||
const existing = db
|
||||
export async function updateSourceWatermark(lastSourceFinishedAt: string | null): Promise<void> {
|
||||
const existing = (await db
|
||||
.select()
|
||||
.from(alertEngineCursor)
|
||||
.where(eq(alertEngineCursor.id, 1))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
.limit(1))[0]
|
||||
if (existing) {
|
||||
db.update(alertEngineCursor)
|
||||
await db.update(alertEngineCursor)
|
||||
.set({
|
||||
lastSourceFinishedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(alertEngineCursor.id, 1))
|
||||
.run()
|
||||
return
|
||||
}
|
||||
db.insert(alertEngineCursor)
|
||||
await db.insert(alertEngineCursor)
|
||||
.values({
|
||||
id: 1,
|
||||
lastSourceFinishedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asc, desc, eq, sql } from "drizzle-orm"
|
||||
import { db, sqliteDatabase } from "../db/index.js"
|
||||
import { db, dbAll } from "../db/index.js"
|
||||
import {
|
||||
alertGroups,
|
||||
alertHistory,
|
||||
@@ -55,24 +55,22 @@ export type ApiAlertHistoryEntry = {
|
||||
source: "db" | "live"
|
||||
}
|
||||
|
||||
function ensureTelegramRow() {
|
||||
let row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0]
|
||||
async function ensureTelegramRow() {
|
||||
let row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
|
||||
if (!row) {
|
||||
db.insert(alertTelegramSettings).values({ id: 1 }).run()
|
||||
row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0]
|
||||
await db.insert(alertTelegramSettings).values({ id: 1 })
|
||||
row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
|
||||
}
|
||||
return row!
|
||||
}
|
||||
|
||||
function lastFiredByRuleId(): Map<string, string> {
|
||||
const rows = sqliteDatabase
|
||||
.prepare(
|
||||
`SELECT rule_id AS ruleId, MAX(fired_at) AS mx
|
||||
FROM alert_history
|
||||
WHERE rule_id IS NOT NULL AND rule_id != ''
|
||||
GROUP BY rule_id`,
|
||||
)
|
||||
.all() as { ruleId: string; mx: string }[]
|
||||
async function lastFiredByRuleId(): Promise<Map<string, string>> {
|
||||
const rows = await dbAll<{ ruleId: string; mx: string }>(
|
||||
`SELECT rule_id AS "ruleId", MAX(fired_at) AS mx
|
||||
FROM alert_history
|
||||
WHERE rule_id IS NOT NULL AND rule_id != ''
|
||||
GROUP BY rule_id`,
|
||||
)
|
||||
const m = new Map<string, string>()
|
||||
for (const r of rows) {
|
||||
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx)
|
||||
@@ -80,12 +78,11 @@ function lastFiredByRuleId(): Map<string, string> {
|
||||
return m
|
||||
}
|
||||
|
||||
function targetsByRuleId(): Map<string, string[]> {
|
||||
const rows = db
|
||||
async function targetsByRuleId(): Promise<Map<string, string[]>> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertRuleTargets)
|
||||
.orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id))
|
||||
.all()
|
||||
const m = new Map<string, string[]>()
|
||||
for (const t of rows) {
|
||||
const rid = t.ruleId
|
||||
@@ -96,12 +93,11 @@ function targetsByRuleId(): Map<string, string[]> {
|
||||
return m
|
||||
}
|
||||
|
||||
function conditionsByRuleId(): Map<string, string[]> {
|
||||
const rows = db
|
||||
async function conditionsByRuleId(): Promise<Map<string, string[]>> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertRuleConditions)
|
||||
.orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id))
|
||||
.all()
|
||||
const m = new Map<string, string[]>()
|
||||
for (const t of rows) {
|
||||
const rid = t.ruleId
|
||||
@@ -112,26 +108,25 @@ function conditionsByRuleId(): Map<string, string[]> {
|
||||
return m
|
||||
}
|
||||
|
||||
export function listAlertGroups(): ApiAlertGroup[] {
|
||||
return db
|
||||
export async function listAlertGroups(): Promise<ApiAlertGroup[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertGroups)
|
||||
.orderBy(asc(alertGroups.name))
|
||||
.all()
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
combineMode: g.combineMode,
|
||||
enabled: g.enabled,
|
||||
cooldownOverride: g.cooldownOverride ?? null,
|
||||
}))
|
||||
return rows.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
combineMode: g.combineMode,
|
||||
enabled: g.enabled,
|
||||
cooldownOverride: g.cooldownOverride ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
export function listAlertRules(): ApiAlertRule[] {
|
||||
const lf = lastFiredByRuleId()
|
||||
const tgtMap = targetsByRuleId()
|
||||
const condMap = conditionsByRuleId()
|
||||
const rows = db.select().from(alertRules).all()
|
||||
export async function listAlertRules(): Promise<ApiAlertRule[]> {
|
||||
const lf = await lastFiredByRuleId()
|
||||
const tgtMap = await targetsByRuleId()
|
||||
const condMap = await conditionsByRuleId()
|
||||
const rows = await db.select().from(alertRules)
|
||||
return rows.map((r) => {
|
||||
const targets = tgtMap.get(r.id)
|
||||
const ts = targets?.length ? targets : [r.target]
|
||||
@@ -207,23 +202,23 @@ export type ReplaceAlertGroupInput = {
|
||||
}
|
||||
|
||||
/** Атомарно заменяет группы, правила и строки targets. */
|
||||
export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
|
||||
const now = sql`(datetime('now'))`
|
||||
export async function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
|
||||
const now = sql`now()`
|
||||
const { groups, rules } = payload
|
||||
db.transaction((tx) => {
|
||||
tx.delete(alertRuleConditions).run()
|
||||
tx.delete(alertRuleTargets).run()
|
||||
tx.delete(alertRules).run()
|
||||
tx.delete(alertGroups).run()
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(alertRuleConditions)
|
||||
await tx.delete(alertRuleTargets)
|
||||
await tx.delete(alertRules)
|
||||
await tx.delete(alertGroups)
|
||||
for (const g of groups) {
|
||||
tx.insert(alertGroups).values({
|
||||
await tx.insert(alertGroups).values({
|
||||
id: g.id,
|
||||
name: g.name.trim() || g.id,
|
||||
combineMode: g.combineMode,
|
||||
enabled: g.enabled,
|
||||
cooldownOverride: g.cooldownOverride ?? null,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
})
|
||||
}
|
||||
for (const r of rules) {
|
||||
const rawTargets =
|
||||
@@ -250,7 +245,7 @@ export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[];
|
||||
r.recoveryStabilitySec > 0
|
||||
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
|
||||
: null
|
||||
tx.insert(alertRules).values({
|
||||
await tx.insert(alertRules).values({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
type: r.type,
|
||||
@@ -265,39 +260,39 @@ export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[];
|
||||
chatId: r.chatId ?? "",
|
||||
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
rawTargets.forEach((t, i) => {
|
||||
tx.insert(alertRuleTargets).values({
|
||||
})
|
||||
for (const [i, t] of rawTargets.entries()) {
|
||||
await tx.insert(alertRuleTargets).values({
|
||||
id: `rt-${r.id}-${i}`,
|
||||
ruleId: r.id,
|
||||
target: t,
|
||||
sortIndex: i,
|
||||
}).run()
|
||||
})
|
||||
rawConds.forEach((c, i) => {
|
||||
tx.insert(alertRuleConditions).values({
|
||||
})
|
||||
}
|
||||
for (const [i, c] of rawConds.entries()) {
|
||||
await tx.insert(alertRuleConditions).values({
|
||||
id: `rc-${r.id}-${i}`,
|
||||
ruleId: r.id,
|
||||
conditionLine: c,
|
||||
sortIndex: i,
|
||||
}).run()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Совместимость: только правила, группы не трогаем. */
|
||||
export function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
|
||||
const curGroups = listAlertGroups()
|
||||
replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
|
||||
export async function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
|
||||
const curGroups = await listAlertGroups()
|
||||
await replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
|
||||
}
|
||||
|
||||
export function getTelegramSettingsRow() {
|
||||
return ensureTelegramRow()
|
||||
export async function getTelegramSettingsRow() {
|
||||
return await ensureTelegramRow()
|
||||
}
|
||||
|
||||
export function getTelegramPublic() {
|
||||
const row = ensureTelegramRow()
|
||||
export async function getTelegramPublic() {
|
||||
const row = await ensureTelegramRow()
|
||||
const tid = row.messageThreadId
|
||||
const messageThreadId =
|
||||
typeof tid === "number" && Number.isFinite(tid) && Math.floor(tid) >= 1 ? Math.floor(tid) : null
|
||||
@@ -308,13 +303,13 @@ export function getTelegramPublic() {
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTelegramSettings(patch: {
|
||||
export async function updateTelegramSettings(patch: {
|
||||
token?: string | null
|
||||
chatId?: string | undefined
|
||||
/** undefined — не менять; null — сбросить тему */
|
||||
messageThreadId?: number | null
|
||||
}) {
|
||||
const cur = ensureTelegramRow()
|
||||
const cur = await ensureTelegramRow()
|
||||
let nextToken = cur.botToken
|
||||
let nextChat = cur.chatId ?? ""
|
||||
let nextThread: number | null | undefined = undefined
|
||||
@@ -335,27 +330,26 @@ export function updateTelegramSettings(patch: {
|
||||
const base = {
|
||||
botToken: nextToken,
|
||||
chatId: nextChat,
|
||||
updatedAt: sql`(datetime('now'))`,
|
||||
updatedAt: sql`now()`,
|
||||
}
|
||||
if (nextThread !== undefined) {
|
||||
db.update(alertTelegramSettings)
|
||||
await db.update(alertTelegramSettings)
|
||||
.set({ ...base, messageThreadId: nextThread })
|
||||
.where(eq(alertTelegramSettings.id, 1))
|
||||
.run()
|
||||
} else {
|
||||
db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1)).run()
|
||||
await db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1))
|
||||
}
|
||||
return getTelegramPublic()
|
||||
return await getTelegramPublic()
|
||||
}
|
||||
|
||||
/** Токен только для внутреннего вызова Telegram API (не отдаётся клиенту). */
|
||||
export function getTelegramBotToken(): string {
|
||||
return ensureTelegramRow().botToken?.trim() ?? ""
|
||||
export async function getTelegramBotToken(): Promise<string> {
|
||||
return (await ensureTelegramRow()).botToken?.trim() ?? ""
|
||||
}
|
||||
|
||||
/** Для `sendMessage`: только если в БД задана валидная тема. */
|
||||
export function getTelegramMessageThreadIdForApi(): number | undefined {
|
||||
const { messageThreadId } = getTelegramPublic()
|
||||
export async function getTelegramMessageThreadIdForApi(): Promise<number | undefined> {
|
||||
const { messageThreadId } = await getTelegramPublic()
|
||||
return messageThreadId ?? undefined
|
||||
}
|
||||
|
||||
@@ -390,9 +384,9 @@ export async function sendTelegramAlertMessage(opts: {
|
||||
/** Переопределение темы; undefined — как в настройках */
|
||||
messageThreadId?: number | null
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const token = (opts.token?.trim() || getTelegramBotToken()).trim()
|
||||
const token = (opts.token?.trim() || await getTelegramBotToken()).trim()
|
||||
if (!token) return { ok: false, error: "Не задан Bot Token" }
|
||||
const pub = getTelegramPublic()
|
||||
const pub = await getTelegramPublic()
|
||||
const chatId = (opts.chatId?.trim() || pub.chatId || "").trim()
|
||||
if (!chatId) return { ok: false, error: "Не задан Chat ID" }
|
||||
const threadId =
|
||||
@@ -400,7 +394,7 @@ export async function sendTelegramAlertMessage(opts: {
|
||||
? opts.messageThreadId >= 1
|
||||
? Math.floor(opts.messageThreadId)
|
||||
: undefined
|
||||
: getTelegramMessageThreadIdForApi()
|
||||
: await getTelegramMessageThreadIdForApi()
|
||||
const url = `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`
|
||||
try {
|
||||
const payload: { chat_id: string; text: string; message_thread_id?: number } = {
|
||||
@@ -423,7 +417,7 @@ export async function sendTelegramAlertMessage(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
export function appendAlertHistory(entry: {
|
||||
export async function appendAlertHistory(entry: {
|
||||
id: string
|
||||
ruleId?: string | null
|
||||
groupId?: string | null
|
||||
@@ -434,7 +428,7 @@ export function appendAlertHistory(entry: {
|
||||
firedAt?: string
|
||||
}) {
|
||||
const firedAt = entry.firedAt ?? new Date().toISOString()
|
||||
db.insert(alertHistory)
|
||||
await db.insert(alertHistory)
|
||||
.values({
|
||||
id: entry.id,
|
||||
ruleId: entry.ruleId ?? null,
|
||||
@@ -445,7 +439,6 @@ export function appendAlertHistory(entry: {
|
||||
sentOk: entry.sentOk,
|
||||
firedAt,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
interface LiveResRow {
|
||||
@@ -462,38 +455,38 @@ interface LiveProbeRow {
|
||||
lossPct: number | null
|
||||
}
|
||||
|
||||
function loadLiveResourceIssues(): LiveResRow[] {
|
||||
return sqliteDatabase
|
||||
.prepare(
|
||||
`SELECT s.name AS serverName, urs.sampled_at AS sampledAt
|
||||
FROM uptime_resource_samples urs
|
||||
INNER JOIN (
|
||||
SELECT server_id, MAX(id) AS mid FROM uptime_resource_samples GROUP BY server_id
|
||||
) latest ON latest.mid = urs.id
|
||||
INNER JOIN servers s ON s.id = urs.server_id
|
||||
WHERE urs.status = 'offline'`,
|
||||
)
|
||||
.all() as LiveResRow[]
|
||||
async function loadLiveResourceIssues(): Promise<LiveResRow[]> {
|
||||
return dbAll<LiveResRow>(
|
||||
`SELECT s.name AS "serverName", urs.sampled_at AS "sampledAt"
|
||||
FROM uptime_resource_samples urs
|
||||
INNER JOIN (
|
||||
SELECT DISTINCT ON (server_id) server_id, sampled_at, status
|
||||
FROM uptime_resource_samples
|
||||
ORDER BY server_id, sampled_at DESC
|
||||
) latest ON latest.server_id = urs.server_id AND latest.sampled_at = urs.sampled_at
|
||||
INNER JOIN servers s ON s.id = urs.server_id
|
||||
WHERE urs.status = 'offline'`,
|
||||
)
|
||||
}
|
||||
|
||||
function loadLiveProbeIssues(): LiveProbeRow[] {
|
||||
return sqliteDatabase
|
||||
.prepare(
|
||||
`SELECT p.name AS probeName, p.target AS target, ups.sampled_at AS sampledAt,
|
||||
ups.status AS status, ups.rtt_ms AS rttMs, ups.loss_pct AS lossPct
|
||||
FROM uptime_probe_samples ups
|
||||
INNER JOIN (
|
||||
SELECT probe_id, MAX(id) AS mid FROM uptime_probe_samples GROUP BY probe_id
|
||||
) latest ON latest.mid = ups.id
|
||||
INNER JOIN uptime_probes p ON p.id = ups.probe_id
|
||||
WHERE ups.status IN ('down', 'warn')`,
|
||||
)
|
||||
.all() as LiveProbeRow[]
|
||||
async function loadLiveProbeIssues(): Promise<LiveProbeRow[]> {
|
||||
return dbAll<LiveProbeRow>(
|
||||
`SELECT p.name AS "probeName", p.target AS target, ups.sampled_at AS "sampledAt",
|
||||
ups.status AS status, ups.rtt_ms AS "rttMs", ups.loss_pct AS "lossPct"
|
||||
FROM uptime_probe_samples ups
|
||||
INNER JOIN (
|
||||
SELECT DISTINCT ON (probe_id) probe_id, sampled_at, status, rtt_ms, loss_pct
|
||||
FROM uptime_probe_samples
|
||||
ORDER BY probe_id, sampled_at DESC
|
||||
) latest ON latest.probe_id = ups.probe_id AND latest.sampled_at = ups.sampled_at
|
||||
INNER JOIN uptime_probes p ON p.id = ups.probe_id
|
||||
WHERE ups.status IN ('down', 'warn')`,
|
||||
)
|
||||
}
|
||||
|
||||
function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
|
||||
async function toLiveHistoryEntries(): Promise<ApiAlertHistoryEntry[]> {
|
||||
const out: ApiAlertHistoryEntry[] = []
|
||||
for (const r of loadLiveResourceIssues()) {
|
||||
for (const r of await loadLiveResourceIssues()) {
|
||||
const safeId = `live-res-${r.serverName}-${r.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||
out.push({
|
||||
id: safeId,
|
||||
@@ -505,7 +498,7 @@ function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
|
||||
source: "live",
|
||||
})
|
||||
}
|
||||
for (const p of loadLiveProbeIssues()) {
|
||||
for (const p of await loadLiveProbeIssues()) {
|
||||
const sev = p.status === "down" ? "critical" : "warning"
|
||||
const loss = p.lossPct != null ? `${p.lossPct}%` : "—"
|
||||
const rtt = p.rttMs != null ? `${p.rttMs} мс` : "—"
|
||||
@@ -523,13 +516,12 @@ function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
|
||||
return out
|
||||
}
|
||||
|
||||
export function listPersistedHistory(limit: number): ApiAlertHistoryEntry[] {
|
||||
const rows = db
|
||||
export async function listPersistedHistory(limit: number): Promise<ApiAlertHistoryEntry[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertHistory)
|
||||
.orderBy(desc(alertHistory.firedAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
return rows.map((h) => ({
|
||||
id: h.id,
|
||||
ruleName: h.ruleName,
|
||||
@@ -541,9 +533,9 @@ export function listPersistedHistory(limit: number): ApiAlertHistoryEntry[] {
|
||||
}))
|
||||
}
|
||||
|
||||
export function listMergedHistory(persistLimit = 40, liveCap = 25): ApiAlertHistoryEntry[] {
|
||||
const persisted = listPersistedHistory(persistLimit)
|
||||
const live = toLiveHistoryEntries().slice(0, liveCap)
|
||||
export async function listMergedHistory(persistLimit = 40, liveCap = 25): Promise<ApiAlertHistoryEntry[]> {
|
||||
const persisted = await listPersistedHistory(persistLimit)
|
||||
const live = (await toLiveHistoryEntries()).slice(0, liveCap)
|
||||
const merged = [...persisted, ...live].sort((a, b) => (a.firedAt < b.firedAt ? 1 : a.firedAt > b.firedAt ? -1 : 0))
|
||||
const seen = new Set<string>()
|
||||
const dedup: ApiAlertHistoryEntry[] = []
|
||||
@@ -556,8 +548,8 @@ export function listMergedHistory(persistLimit = 40, liveCap = 25): ApiAlertHist
|
||||
return dedup
|
||||
}
|
||||
|
||||
export function getAlertsMeta() {
|
||||
const srvRows = db
|
||||
export async function getAlertsMeta() {
|
||||
const srvRows = await db
|
||||
.select({
|
||||
name: servers.name,
|
||||
host: servers.host,
|
||||
@@ -566,7 +558,6 @@ export function getAlertsMeta() {
|
||||
type: servers.type,
|
||||
})
|
||||
.from(servers)
|
||||
.all()
|
||||
const labels = srvRows.map((s) => (s.name?.trim() ? s.name.trim() : s.host))
|
||||
const serversDetail = srvRows.map((r) => ({
|
||||
name: r.name?.trim() || "",
|
||||
@@ -575,7 +566,7 @@ export function getAlertsMeta() {
|
||||
country: r.country?.trim() || "",
|
||||
type: (r.type ?? "home-router") as string,
|
||||
}))
|
||||
const probes = db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes).all()
|
||||
const probes = await db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes)
|
||||
const probeTargets = probes.map((p) => `${p.name} → ${p.target}`)
|
||||
const defaults = ["8.8.8.8", "1.1.1.1"]
|
||||
const rttLossTargets: string[] = []
|
||||
|
||||
@@ -18,7 +18,7 @@ export function getBackupSchedulerCollectorState(): { running: boolean } {
|
||||
|
||||
export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot> {
|
||||
const sampledAt = new Date().toISOString()
|
||||
const settings = getBackupScheduleSettings()
|
||||
const settings = await getBackupScheduleSettings()
|
||||
const snapshot: BackupsRunSnapshot = {
|
||||
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
||||
job: "backups",
|
||||
@@ -49,7 +49,7 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
|
||||
if (settings.format !== "rsc") {
|
||||
snapshot.skipped = true
|
||||
snapshot.errors = ["Формат backup пока не поддерживается, используйте rsc"]
|
||||
touchBackupScheduleRunMeta({
|
||||
await touchBackupScheduleRunMeta({
|
||||
lastRunAt: sampledAt,
|
||||
lastDurationMs: 0,
|
||||
lastError: snapshot.errors[0],
|
||||
@@ -59,9 +59,9 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
|
||||
|
||||
collecting = true
|
||||
const started = Date.now()
|
||||
const serverIds = resolveBackupServerIds(settings)
|
||||
const serverIds = await resolveBackupServerIds(settings)
|
||||
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "info",
|
||||
eventType: "backups.job.started",
|
||||
sourceModule: "backups",
|
||||
@@ -88,13 +88,13 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
|
||||
snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount)
|
||||
}
|
||||
|
||||
touchBackupScheduleRunMeta({
|
||||
await touchBackupScheduleRunMeta({
|
||||
lastRunAt: sampledAt,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: snapshot.errors?.length ? snapshot.errors.join("; ") : null,
|
||||
})
|
||||
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: snapshot.failures > 0 ? "warning" : "info",
|
||||
eventType: "backups.job.done",
|
||||
sourceModule: "backups",
|
||||
@@ -115,12 +115,12 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
snapshot.fatalError = message
|
||||
snapshot.errors?.push(message)
|
||||
touchBackupScheduleRunMeta({
|
||||
await touchBackupScheduleRunMeta({
|
||||
lastRunAt: sampledAt,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: message,
|
||||
})
|
||||
appendEvent({
|
||||
await appendEvent({
|
||||
level: "critical",
|
||||
eventType: "backups.job.failed",
|
||||
sourceModule: "backups",
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
|
||||
import { db } from "../db/index.js"
|
||||
import { parseJsonArray } from "../db/json.js"
|
||||
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
|
||||
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||
@@ -14,7 +15,7 @@ const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
||||
|
||||
export type BackupMeta = {
|
||||
id: string
|
||||
serverId: string
|
||||
serverId: number | null
|
||||
serverName: string
|
||||
filename: string
|
||||
sizeBytes: number
|
||||
@@ -40,17 +41,18 @@ export async function ensureBackupStorage(): Promise<void> {
|
||||
await mkdir(BACKUPS_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
export function listBackups(): BackupMeta[] {
|
||||
return db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt)).all().map(rowToMeta)
|
||||
export async function listBackups(): Promise<BackupMeta[]> {
|
||||
const rows = await db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt))
|
||||
return rows.map(rowToMeta)
|
||||
}
|
||||
|
||||
export function getBackupById(id: string): BackupMeta | null {
|
||||
const row = db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1).all()[0]
|
||||
export async function getBackupById(id: string): Promise<BackupMeta | null> {
|
||||
const row = (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
|
||||
return row ? rowToMeta(row) : null
|
||||
}
|
||||
|
||||
export function insertBackup(meta: BackupMeta): void {
|
||||
db.insert(backupEntries).values({
|
||||
export async function insertBackup(meta: BackupMeta): Promise<void> {
|
||||
await db.insert(backupEntries).values({
|
||||
id: meta.id,
|
||||
serverId: meta.serverId,
|
||||
serverName: meta.serverName,
|
||||
@@ -59,13 +61,13 @@ export function insertBackup(meta: BackupMeta): void {
|
||||
kind: meta.kind,
|
||||
notes: meta.notes ?? null,
|
||||
createdAt: meta.createdAt,
|
||||
}).run()
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||
const hit = getBackupById(id)
|
||||
const hit = await getBackupById(id)
|
||||
if (!hit) return null
|
||||
db.delete(backupEntries).where(eq(backupEntries.id, id)).run()
|
||||
await db.delete(backupEntries).where(eq(backupEntries.id, id))
|
||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
||||
return hit
|
||||
}
|
||||
@@ -75,18 +77,12 @@ function fmtTs(d = new Date()): string {
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function parseServerIds(raw: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.map(String).filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
function parseServerIds(raw: unknown): string[] {
|
||||
return parseJsonArray(raw).map(String).filter(Boolean)
|
||||
}
|
||||
|
||||
function getBackupScheduleSettingsRow() {
|
||||
return db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]
|
||||
async function getBackupScheduleSettingsRow() {
|
||||
return (await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]
|
||||
?? {
|
||||
id: SETTINGS_ID,
|
||||
enabled: true,
|
||||
@@ -97,7 +93,7 @@ function getBackupScheduleSettingsRow() {
|
||||
monthDay: 1,
|
||||
keepCount: 7,
|
||||
format: "rsc" as const,
|
||||
serverIdsJson: "[]",
|
||||
serverIdsJson: [],
|
||||
lastRunAt: null,
|
||||
lastDurationMs: null,
|
||||
lastError: null,
|
||||
@@ -105,8 +101,8 @@ function getBackupScheduleSettingsRow() {
|
||||
}
|
||||
}
|
||||
|
||||
export function getBackupScheduleSettings(): BackupScheduleSettingsDto {
|
||||
const row = getBackupScheduleSettingsRow()
|
||||
export async function getBackupScheduleSettings(): Promise<BackupScheduleSettingsDto> {
|
||||
const row = await getBackupScheduleSettingsRow()
|
||||
return {
|
||||
enabled: row.enabled,
|
||||
frequency: row.frequency,
|
||||
@@ -124,7 +120,7 @@ export function getBackupScheduleSettings(): BackupScheduleSettingsDto {
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBackupScheduleSettings(patch: Partial<{
|
||||
export async function updateBackupScheduleSettings(patch: Partial<{
|
||||
enabled: boolean
|
||||
frequency: "daily" | "weekly" | "monthly"
|
||||
hour: number
|
||||
@@ -135,7 +131,7 @@ export function updateBackupScheduleSettings(patch: Partial<{
|
||||
format: "rsc" | "backup"
|
||||
serverIds: string[]
|
||||
}>) {
|
||||
const prev = getBackupScheduleSettingsRow()
|
||||
const prev = await getBackupScheduleSettingsRow()
|
||||
const now = new Date().toISOString()
|
||||
const next = {
|
||||
enabled: patch.enabled ?? prev.enabled,
|
||||
@@ -146,33 +142,33 @@ export function updateBackupScheduleSettings(patch: Partial<{
|
||||
monthDay: patch.monthDay ?? prev.monthDay,
|
||||
keepCount: patch.keepCount ?? prev.keepCount,
|
||||
format: patch.format ?? prev.format,
|
||||
serverIdsJson: patch.serverIds ? JSON.stringify(patch.serverIds) : prev.serverIdsJson,
|
||||
serverIdsJson: patch.serverIds ?? prev.serverIdsJson,
|
||||
updatedAt: now,
|
||||
}
|
||||
if (db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
|
||||
db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
|
||||
if ((await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]) {
|
||||
await db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID))
|
||||
} else {
|
||||
db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next }).run()
|
||||
await db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next })
|
||||
}
|
||||
return getBackupScheduleSettings()
|
||||
return await getBackupScheduleSettings()
|
||||
}
|
||||
|
||||
export function touchBackupScheduleRunMeta(patch: {
|
||||
export async function touchBackupScheduleRunMeta(patch: {
|
||||
lastRunAt?: string
|
||||
lastDurationMs?: number
|
||||
lastError?: string | null
|
||||
}) {
|
||||
const prev = getBackupScheduleSettingsRow()
|
||||
db.update(backupScheduleSettings).set({
|
||||
const prev = await getBackupScheduleSettingsRow()
|
||||
await db.update(backupScheduleSettings).set({
|
||||
lastRunAt: patch.lastRunAt ?? prev.lastRunAt,
|
||||
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
|
||||
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
|
||||
}).where(eq(backupScheduleSettings.id, SETTINGS_ID))
|
||||
}
|
||||
|
||||
export function resolveBackupServerIds(settings: BackupScheduleSettingsDto): string[] {
|
||||
const enabled = new Set(listServersRead().map((s) => String(s.id)))
|
||||
export async function resolveBackupServerIds(settings: BackupScheduleSettingsDto): Promise<string[]> {
|
||||
const enabled = new Set((await listServersRead()).map((s) => String(s.id)))
|
||||
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
|
||||
return [...new Set(requested)].filter((id) => enabled.has(id))
|
||||
}
|
||||
@@ -227,7 +223,7 @@ export async function runBackupForServer(
|
||||
if (!Number.isFinite(serverIdNum)) {
|
||||
throw new Error("Невалидный id сервера")
|
||||
}
|
||||
const row = getServerRowById(serverIdNum)
|
||||
const row = await getServerRowById(serverIdNum)
|
||||
if (!row) {
|
||||
throw new Error("Сервер не найден")
|
||||
}
|
||||
@@ -242,7 +238,7 @@ export async function runBackupForServer(
|
||||
const st = await stat(filePath)
|
||||
const meta: BackupMeta = {
|
||||
id: randomUUID(),
|
||||
serverId: String(row.id),
|
||||
serverId: row.id,
|
||||
serverName: row.name,
|
||||
filename,
|
||||
sizeBytes: st.size,
|
||||
@@ -250,19 +246,18 @@ export async function runBackupForServer(
|
||||
kind,
|
||||
notes,
|
||||
}
|
||||
insertBackup(meta)
|
||||
await insertBackup(meta)
|
||||
return meta
|
||||
}
|
||||
|
||||
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
|
||||
const rows = db.select().from(backupEntries)
|
||||
.where(eq(backupEntries.serverId, serverId))
|
||||
const rows = await db.select().from(backupEntries)
|
||||
.where(eq(backupEntries.serverId, Number(serverId)))
|
||||
.orderBy(desc(backupEntries.createdAt))
|
||||
.all()
|
||||
if (rows.length <= keepCount) return 0
|
||||
const toDelete = rows.slice(keepCount)
|
||||
for (const hit of toDelete) {
|
||||
db.delete(backupEntries).where(eq(backupEntries.id, hit.id)).run()
|
||||
await db.delete(backupEntries).where(eq(backupEntries.id, hit.id))
|
||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
||||
}
|
||||
return toDelete.length
|
||||
|
||||
@@ -14,7 +14,7 @@ export function bgpPeerAlertKey(s: BgpSessionRead): string {
|
||||
|
||||
/** Агрегат BGP-сессий с включённых серверов — как `/api/bgp/sessions`. */
|
||||
export async function fetchBgpSessionsForAlerts(): Promise<BgpSessionRead[]> {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const results = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user