Compare commits

..
7 Commits
Author SHA1 Message Date
Denozordec a80caf5676 feat(netflow): локальные GeoLite2-базы для стран и ASN потоков
Docker images / prepare-release (push) Successful in 13s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 4m31s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m1s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Lookup страны/ASN при ingest теперь идёт сначала по локальным mmdb
MaxMind GeoLite2 (Country + ASN, зеркало P3TERX, без ключей), с
мгновенным синхронным ответом для IPv4/IPv6; RIPEstat остаётся
fallback до первой загрузки баз и при промахе.

- backend/src/services/traffic-flow-geoip.ts: ридеры maxmind, фасад
  resolveFlowIp (geoip-first → RIPE-кэш), статус ридеров
- geoip-update-collector.ts: conditional GET по ETag, валидация пробоем
  8.8.8.8 (US / AS15169), атомарная подмена с .prev-откатом
- джоба планировщика geoip_update (по умолчанию раз в 7 дней),
  настройки geoip_settings + миграция 0004, API /api/geoip
  (GET/PUT/update), контракты @mmapp/contracts/geoip
- engine/analytics/map-hops переведены на resolveFlowIp
- секция «GeoIP-базы» в настройках NetFlow, метки джобы на странице
  сбора данных, тесты test:geoip
2026-09-09 23:33:31 +07:00
DenozordecandCursor d39e3454aa feat(network-map): показывать ASN-бренды по доле среди сервисов
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m5s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Долю считать среди именованных брендов, а не от всего окна. На карте всегда оставлять топ-8 и добавить иконки новых брендов.

Co-authored-by: Cursor <[email protected]>
2026-09-09 16:53:06 +07:00
DenozordecandCursor a8f2055c77 feat(netflow): расширить каталог ASN популярных сервисов
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m5s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 4m2s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
В аналитике потока видны сети со своим AS (Apple, Steam, VK, Epic и другие), а не только Cloudflare/AWS. Holder и порты Steam не перебивают Cloudflare CIDR.

Co-authored-by: Cursor <[email protected]>
2026-09-09 13:18:58 +07:00
DenozordecandCursor cdeb97d841 feat(certificates): добавить отключение автообновления на странице сертификатов
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m7s
Docker images / frontend-image (push) Successful in 3m19s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Co-authored-by: Cursor <[email protected]>
2026-09-08 16:21:50 +07:00
DenozordecandCursor 36c5305db7 feat(dashboard): собрать ops-дашборд на ReUI Frame с живыми данными
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <[email protected]>
2026-09-08 15:13:27 +07:00
Denozordec 5f2b4e2d40 feat(backups): replace button with checkbox for server selection in backup settings
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m4s
Docker images / frontend-image (push) Successful in 3m10s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m29s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Updated the BackupCreateSheet and BackupsSettings components to use checkboxes for selecting servers instead of buttons. This improves user interaction by allowing for easier selection and toggling of server states. The Checkbox component is now integrated for better accessibility and functionality.
2026-09-08 14:47:44 +07:00
DenozordecandCursor cff26813b9 feat(backups): добавить S3-хранилище и обновить экран бэкапов
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 4m37s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 16s
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами.
Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление.

Co-authored-by: Cursor <[email protected]>
2026-09-08 14:06:27 +07:00
63 changed files with 5913 additions and 1643 deletions
+8
View File
@@ -190,6 +190,14 @@ npm run build -w @mmapp/contracts
npm --prefix backend run db:migrate-from-sqlite
```
### GeoIP-базы GeoLite2 (страны и ASN для NetFlow)
Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country + ASN) в `backend/storage/geoip/` и скачивает их с зеркала [P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb) — без регистрации и ключей. Lookup страны/ASN потока при ingest становится мгновенным (включая IPv6) и не упирается в лимиты RIPEstat; пока базы не скачаны или lookup промахнулся, работает прежний RIPE-fallback.
Управление — секция «GeoIP-базы (GeoLite2)» в настройках NetFlow (страница «Сбор данных»): автообновление (по умолчанию проверка раз в 7 дней, upstream обновляется еженедельно), статус сборки баз и кнопка «Обновить сейчас». Джоба планировщика — `geoip_update`. Атрибуция: данные MaxMind GeoLite2, CC BY-SA 4.0.
Примечание для Docker: каталог `storage/geoip` внутри контейнера ephemeral — без смонтированного volume базы (~17 МБ) перекачаются после пересоздания контейнера. Каталог переопределяется переменной `GEOIP_DIR`.
## CI/CD (Gitea Actions)
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -11,6 +11,7 @@ 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 { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
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"
@@ -243,7 +244,7 @@ function CertPartReference() {
return (
<OpsPanel
title="RouterOS 7 · /certificate — справка CLI"
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
contentClassName="px-5 py-4"
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
@@ -715,6 +716,8 @@ export default function CertificatesPage() {
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
{liveReady && (
<CertPartAcmeSettings
acmeDirectoryUrl={acmeDirectoryUrl}
File diff suppressed because it is too large Load Diff
+40
View File
@@ -34,6 +34,7 @@ import {
type InternetPathRunSnapshot,
type CertificatesRenewRunSnapshot,
type BackupsRunSnapshot,
type GeoipUpdateRunSnapshot,
type PingRunSnapshot,
type ResourcesRunSnapshot,
type SchedulerRunSnapshot,
@@ -340,6 +341,41 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
)
}
if (snap.job === "geoip_update") {
const g = snap as GeoipUpdateRunSnapshot
return (
<div className="flex flex-col gap-3">
{g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: обновление уже выполнялось или задача отключена.</p>
) : null}
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Баз проверено</dt>
<dd className="font-mono font-medium">{g.checked}</dd>
</div>
<div>
<dt className="text-muted-foreground">Скачано</dt>
<dd className="font-mono font-medium">{g.downloaded}</dd>
</div>
<div>
<dt className="text-muted-foreground">Без изменений</dt>
<dd className="font-mono font-medium">{g.skippedUnchanged}</dd>
</div>
<div>
<dt className="text-muted-foreground">Объём, МБ</dt>
<dd className="font-mono font-medium">{(g.bytes / 1024 / 1024).toFixed(1)}</dd>
</div>
</dl>
{g.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
return (
@@ -1127,7 +1163,11 @@ export default function DataCollectionPage() {
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
className="h-8 text-sm"
inputMode="numeric"
disabled={!draftCertRenewEnabled}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
</p>
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
+1
View File
@@ -5,3 +5,4 @@ dist/
*.db-wal
.env
storage/backups/
storage/geoip/
+44
View File
@@ -0,0 +1,44 @@
-- S3-compatible storage for RouterOS backups
CREATE TABLE IF NOT EXISTS backup_storage_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
s3_endpoint TEXT NOT NULL DEFAULT '',
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
s3_bucket TEXT NOT NULL DEFAULT '',
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
s3_access_key_id TEXT NOT NULL DEFAULT '',
s3_secret_access_key TEXT NOT NULL DEFAULT '',
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
last_test_at TIMESTAMPTZ,
last_test_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO backup_storage_settings (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS s3_key TEXT;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS upload_error TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
) THEN
ALTER TABLE backup_entries
ADD CONSTRAINT backup_entries_storage_check
CHECK (storage IN ('local', 's3', 'both'));
END IF;
END $$;
+19
View File
@@ -0,0 +1,19 @@
-- GeoLite2 mmdb (страна/ASN для netflow): настройки автообновления зеркала P3TERX
CREATE TABLE IF NOT EXISTS geoip_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
update_interval_sec INTEGER NOT NULL DEFAULT 604800,
last_check_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
last_error TEXT,
country_build_at TIMESTAMPTZ,
asn_build_at TIMESTAMPTZ,
etags_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO geoip_settings (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
+6 -2
View File
@@ -15,12 +15,15 @@
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-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: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 && tsx src/services/traffic-flow-geoip.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/sqlite-json.test.ts && tsx src/db/traffic-flags.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"
"test:backups": "tsx src/services/s3-backup-client.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 && npm run test:backups",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
@@ -31,6 +34,7 @@
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"maxmind": "^5.0.7",
"pg": "^8.23.0",
"undici": "^8.1.0",
"zod": "^4.4.1"
+34
View File
@@ -231,6 +231,20 @@ export const flowBuckets = pgTable("flow_buckets", {
index("idx_flow_buckets_server_time").on(t.serverId, t.bucketAt),
])
export const geoipSettings = pgTable("geoip_settings", {
id: idSingleton(),
enabled: boolean("enabled").notNull().default(true),
updateIntervalSec: integer("update_interval_sec").notNull().default(604800),
lastCheckAt: ts("last_check_at"),
lastSuccessAt: ts("last_success_at"),
lastError: text("last_error"),
countryBuildAt: ts("country_build_at"),
asnBuildAt: ts("asn_build_at"),
etagsJson: jsonb("etags_json").notNull().default(sql`'{}'::jsonb`),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const flowIpMeta = pgTable("flow_ip_meta", {
prefix: text("prefix").primaryKey(),
asn: integer("asn").notNull().default(0),
@@ -420,6 +434,22 @@ export const backupScheduleSettings = pgTable("backup_schedule_settings", {
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const backupStorageSettings = pgTable("backup_storage_settings", {
id: idSingleton(),
provider: text("provider", { enum: ["local", "s3"] }).notNull().default("local"),
s3Endpoint: text("s3_endpoint").notNull().default(""),
s3Region: text("s3_region").notNull().default("us-east-1"),
s3Bucket: text("s3_bucket").notNull().default(""),
s3Prefix: text("s3_prefix").notNull().default("mikrotik"),
s3AccessKeyId: text("s3_access_key_id").notNull().default(""),
s3SecretAccessKey: text("s3_secret_access_key").notNull().default(""),
s3ForcePathStyle: boolean("s3_force_path_style").notNull().default(true),
keepLocalCopy: boolean("keep_local_copy").notNull().default(true),
lastTestAt: ts("last_test_at"),
lastTestError: text("last_test_error"),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const backupEntries = pgTable("backup_entries", {
id: text("id").primaryKey(),
serverId: bigint("server_id", { mode: "number" })
@@ -429,6 +459,10 @@ export const backupEntries = pgTable("backup_entries", {
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
notes: text("notes"),
storage: text("storage", { enum: ["local", "s3", "both"] }).notNull().default("local"),
s3Key: text("s3_key"),
s3Etag: text("s3_etag"),
uploadError: text("upload_error"),
createdAt: ts("created_at").notNull(),
}, (t) => [
uniqueIndex("idx_backup_entries_filename").on(t.filename),
+6
View File
@@ -81,6 +81,12 @@ const TABLES: TableCopy[] = [
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
["last_error", "text"], ["updated_at", "ts"],
]},
{ table: "backup_storage_settings", upsert: true, columns: [
["id", "int"], ["provider", "text"], ["s3_endpoint", "text"], ["s3_region", "text"],
["s3_bucket", "text"], ["s3_prefix", "text"], ["s3_access_key_id", "text"],
["s3_secret_access_key", "text"], ["s3_force_path_style", "bool"], ["keep_local_copy", "bool"],
["last_test_at", "ts"], ["last_test_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"],
+4
View File
@@ -14,6 +14,7 @@ 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 geoipRoutes from "./routes/geoip.js"
import serversApiPingRoutes from "./routes/servers-api-ping.js"
import uptimeRoutes from "./routes/uptime.js"
import networkRoutes from "./routes/network.js"
@@ -32,6 +33,7 @@ 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"
import { initGeoip } from "./services/traffic-flow-geoip.js"
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
eventLoopDelay.enable()
@@ -116,6 +118,7 @@ export async function buildApp(opts?: {
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(trafficFlowRoutes, { prefix: "/api" })
await app.register(geoipRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
@@ -135,6 +138,7 @@ export async function buildApp(opts?: {
if (opts?.startScheduler !== false) {
await refreshScheduler()
await initGeoip()
await startTrafficFlowListener()
app.addHook("onClose", async () => {
stopScheduler()
+71 -10
View File
@@ -1,20 +1,26 @@
import { randomUUID } from "node:crypto"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
import {
putBackupScheduleSettingsSchema,
putBackupStorageSettingsSchema,
} from "@mmapp/contracts/backups"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { appendEvent } from "../modules/events/service/events-service.js"
import { refreshScheduler } from "../services/scheduler.js"
import {
deleteBackupRecord,
getBackupById,
getBackupsDir,
getBackupScheduleSettings,
getBackupStorageSettings,
listBackups,
readBackupContent,
restoreBackupToDevice,
runBackupForServer,
syncBackupsFromS3,
testBackupStorageConnection,
updateBackupScheduleSettings,
updateBackupStorageSettings,
type BackupMeta,
} from "../services/backup-service.js"
@@ -97,6 +103,39 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send(result)
})
app.get("/backups/storage", async (_req, reply) => {
return reply.send(await getBackupStorageSettings())
})
app.put("/backups/storage", async (req, reply) => {
const parsed = putBackupStorageSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
return reply.send(await updateBackupStorageSettings(parsed.data))
})
app.post("/backups/storage/test", async (_req, reply) => {
try {
return reply.send(await testBackupStorageConnection())
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Ошибка проверки S3",
settings: await getBackupStorageSettings(),
})
}
})
app.post("/backups/storage/sync", async (_req, reply) => {
try {
return reply.send(await syncBackupsFromS3())
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Ошибка синхронизации S3",
})
}
})
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
@@ -169,12 +208,34 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
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)
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
reply.header("Content-Type", "text/plain; charset=utf-8")
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
return reply.send(content)
try {
const content = await readBackupContent(hit)
reply.header("Content-Type", "text/plain; charset=utf-8")
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
return reply.send(content)
} catch {
return reply.status(404).send({ error: "Файл бэкапа не найден" })
}
})
app.post("/backups/:id/restore", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
try {
const result = await restoreBackupToDevice(req.params.id)
await appendEvent({
level: "warning",
eventType: "backups.restore",
sourceModule: "backups",
title: "Восстановление бэкапа",
message: `${result.filename}${result.serverName}`,
entityType: "backup",
entityId: req.params.id,
})
return reply.send(result)
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Не удалось восстановить бэкап",
})
}
})
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
+57
View File
@@ -0,0 +1,57 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { geoipSettingsPatchSchema } from "@mmapp/contracts/geoip"
import { refreshScheduler } from "../services/scheduler.js"
import { getGeoipSettings, updateGeoipSettings } from "../services/geoip-settings.js"
import {
GEOIP_ASN_FILE,
GEOIP_COUNTRY_FILE,
geoipReadersStatus,
initGeoip,
} from "../services/traffic-flow-geoip.js"
import { collectGeoipUpdateOnce, getGeoipUpdateState } from "../services/geoip-update-collector.js"
async function buildGeoipStatus() {
await initGeoip()
const readers = geoipReadersStatus()
return {
ready: readers.countryLoaded && readers.asnLoaded,
countryLoaded: readers.countryLoaded,
asnLoaded: readers.asnLoaded,
countryFile: GEOIP_COUNTRY_FILE,
asnFile: GEOIP_ASN_FILE,
dir: readers.dir,
running: getGeoipUpdateState().running,
settings: await getGeoipSettings(),
}
}
const geoipRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/geoip", async (_req, reply) => {
return reply.send(await buildGeoipStatus())
})
app.put("/geoip", async (req, reply) => {
const parsed = geoipSettingsPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply
.status(400)
.send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
await updateGeoipSettings(parsed.data)
await refreshScheduler()
return reply.send({ ok: true, status: await buildGeoipStatus() })
})
app.post("/geoip/update", async (_req, reply) => {
try {
const snapshot = await collectGeoipUpdateOnce({ force: true })
return reply.send({ ok: !snapshot.fatalError && snapshot.errors.length === 0, snapshot })
} 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 })
}
})
}
export default geoipRoutes
+301 -12
View File
@@ -1,14 +1,30 @@
import { randomUUID } from "node:crypto"
import { mkdir, rm, stat, writeFile } from "node:fs/promises"
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
import path from "node:path"
import { desc, eq } from "drizzle-orm"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import type {
BackupScheduleSettingsDto,
BackupStorageSettingsDto,
PutBackupStorageSettings,
} from "@mmapp/contracts/backups"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { MikrotikClient } from "./mikrotik.js"
import {
buildS3ObjectKey,
createS3ClientFromConfig,
parseS3ObjectKey,
sanitizeServerName,
s3DeleteObject,
s3GetObject,
s3ListObjects,
s3PutObject,
s3TestConnection,
type S3BackupConfig,
} from "./s3-backup-client.js"
const SETTINGS_ID = 1
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
@@ -22,9 +38,14 @@ export type BackupMeta = {
createdAt: string
kind: "manual" | "auto"
notes?: string
storage: "local" | "s3" | "both"
s3Key?: string | null
uploadError?: string | null
}
function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
type BackupRow = typeof backupEntries.$inferSelect
function rowToMeta(row: BackupRow): BackupMeta {
return {
id: row.id,
serverId: row.serverId,
@@ -34,6 +55,9 @@ function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
createdAt: row.createdAt,
kind: row.kind,
notes: row.notes ?? undefined,
storage: row.storage ?? "local",
s3Key: row.s3Key,
uploadError: row.uploadError,
}
}
@@ -60,16 +84,36 @@ export async function insertBackup(meta: BackupMeta): Promise<void> {
sizeBytes: meta.sizeBytes,
kind: meta.kind,
notes: meta.notes ?? null,
storage: meta.storage,
s3Key: meta.s3Key ?? null,
s3Etag: null,
uploadError: meta.uploadError ?? null,
createdAt: meta.createdAt,
})
}
async function getBackupRow(id: string): Promise<BackupRow | undefined> {
return (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
}
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
const hit = await getBackupById(id)
if (!hit) return null
const row = await getBackupRow(id)
if (!row) return null
const meta = rowToMeta(row)
if (row.s3Key) {
try {
const cfg = await getS3ConfigIfEnabled()
if (cfg) {
const client = createS3ClientFromConfig(cfg)
await s3DeleteObject(client, cfg.bucket, row.s3Key)
}
} catch {
/* объект мог уже отсутствовать */
}
}
await db.delete(backupEntries).where(eq(backupEntries.id, id))
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
return hit
await rm(path.join(BACKUPS_DIR, row.filename), { force: true })
return meta
}
function fmtTs(d = new Date()): string {
@@ -167,6 +211,127 @@ export async function touchBackupScheduleRunMeta(patch: {
}).where(eq(backupScheduleSettings.id, SETTINGS_ID))
}
function defaultStorageRow() {
return {
id: SETTINGS_ID,
provider: "local" as const,
s3Endpoint: "",
s3Region: "us-east-1",
s3Bucket: "",
s3Prefix: "mikrotik",
s3AccessKeyId: "",
s3SecretAccessKey: "",
s3ForcePathStyle: true,
keepLocalCopy: true,
lastTestAt: null as string | null,
lastTestError: null as string | null,
updatedAt: new Date().toISOString(),
}
}
async function getBackupStorageSettingsRow() {
return (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
?? defaultStorageRow()
}
function toStorageDto(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): BackupStorageSettingsDto {
return {
provider: row.provider,
s3Endpoint: row.s3Endpoint,
s3Region: row.s3Region,
s3Bucket: row.s3Bucket,
s3Prefix: row.s3Prefix,
s3AccessKeyId: row.s3AccessKeyId,
secretConfigured: Boolean(row.s3SecretAccessKey),
s3ForcePathStyle: row.s3ForcePathStyle,
keepLocalCopy: row.keepLocalCopy,
lastTestAt: row.lastTestAt ?? null,
lastTestError: row.lastTestError ?? null,
updatedAt: row.updatedAt,
}
}
export async function getBackupStorageSettings(): Promise<BackupStorageSettingsDto> {
return toStorageDto(await getBackupStorageSettingsRow())
}
export async function updateBackupStorageSettings(
patch: PutBackupStorageSettings,
): Promise<BackupStorageSettingsDto> {
const prev = await getBackupStorageSettingsRow()
const now = new Date().toISOString()
const secret = patch.s3SecretAccessKey
const next = {
provider: patch.provider ?? prev.provider,
s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint,
s3Region: patch.s3Region ?? prev.s3Region,
s3Bucket: patch.s3Bucket ?? prev.s3Bucket,
s3Prefix: patch.s3Prefix ?? prev.s3Prefix,
s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
s3SecretAccessKey: secret && secret.length > 0 ? secret : prev.s3SecretAccessKey,
s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle,
keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
updatedAt: now,
}
if ((await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]) {
await db.update(backupStorageSettings).set(next).where(eq(backupStorageSettings.id, SETTINGS_ID))
} else {
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...next })
}
return await getBackupStorageSettings()
}
function rowToS3Config(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): S3BackupConfig | null {
if (row.provider !== "s3") return null
if (!row.s3Bucket.trim() || !row.s3AccessKeyId.trim() || !row.s3SecretAccessKey) return null
return {
endpoint: row.s3Endpoint,
region: row.s3Region,
bucket: row.s3Bucket.trim(),
prefix: row.s3Prefix,
accessKeyId: row.s3AccessKeyId,
secretAccessKey: row.s3SecretAccessKey,
forcePathStyle: row.s3ForcePathStyle,
}
}
async function getS3ConfigIfEnabled(): Promise<S3BackupConfig | null> {
return rowToS3Config(await getBackupStorageSettingsRow())
}
export async function testBackupStorageConnection(): Promise<BackupStorageSettingsDto> {
const row = await getBackupStorageSettingsRow()
const cfg = rowToS3Config(row)
const now = new Date().toISOString()
if (!cfg) {
const error = row.provider === "s3"
? "Заполните bucket, ключ доступа и секрет"
: "S3 не выбран"
await persistStorageTest(now, error)
throw new Error(error)
}
try {
const client = createS3ClientFromConfig(cfg)
await s3TestConnection(client, cfg.bucket)
await persistStorageTest(now, null)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
await persistStorageTest(now, message)
throw new Error(message)
}
return await getBackupStorageSettings()
}
async function persistStorageTest(at: string, error: string | null) {
const exists = (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
const patch = { lastTestAt: at, lastTestError: error, updatedAt: at }
if (exists) {
await db.update(backupStorageSettings).set(patch).where(eq(backupStorageSettings.id, SETTINGS_ID))
} else {
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...patch })
}
}
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]
@@ -214,6 +379,23 @@ export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, last
return true
}
async function uploadBackupToS3(params: {
serverName: string
filename: string
body: string
}): Promise<{ key: string; etag?: string } | { error: string }> {
const cfg = await getS3ConfigIfEnabled()
if (!cfg) return { error: "S3 не настроен" }
try {
const client = createS3ClientFromConfig(cfg)
const key = buildS3ObjectKey(cfg.prefix, sanitizeServerName(params.serverName), params.filename)
const put = await s3PutObject(client, cfg.bucket, key, params.body)
return { key, etag: put.etag }
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) }
}
}
export async function runBackupForServer(
id: string,
kind: BackupMeta["kind"],
@@ -230,12 +412,33 @@ export async function runBackupForServer(
const client = MikrotikClient.fromServer(row)
const script = await client.exportConfigScript()
const ts = fmtTs()
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
const safeServer = sanitizeServerName(row.name)
const filename = `${safeServer}_${ts}.rsc`
const filePath = path.join(BACKUPS_DIR, filename)
await ensureBackupStorage()
await writeFile(filePath, script, "utf8")
const st = await stat(filePath)
const storageRow = await getBackupStorageSettingsRow()
let storage: BackupMeta["storage"] = "local"
let s3Key: string | null = null
let s3Etag: string | null = null
let uploadError: string | null = null
if (storageRow.provider === "s3") {
const uploaded = await uploadBackupToS3({ serverName: row.name, filename, body: script })
if ("key" in uploaded) {
s3Key = uploaded.key
s3Etag = uploaded.etag ?? null
storage = storageRow.keepLocalCopy ? "both" : "s3"
if (!storageRow.keepLocalCopy) {
await rm(filePath, { force: true })
}
} else {
uploadError = uploaded.error
storage = "local"
}
}
const meta: BackupMeta = {
id: randomUUID(),
serverId: row.id,
@@ -245,8 +448,24 @@ export async function runBackupForServer(
createdAt: new Date().toISOString(),
kind,
notes,
storage,
s3Key,
uploadError,
}
await insertBackup(meta)
await db.insert(backupEntries).values({
id: meta.id,
serverId: meta.serverId,
serverName: meta.serverName,
filename: meta.filename,
sizeBytes: meta.sizeBytes,
kind: meta.kind,
notes: meta.notes ?? null,
storage: meta.storage,
s3Key: meta.s3Key ?? null,
s3Etag,
uploadError: meta.uploadError ?? null,
createdAt: meta.createdAt,
})
return meta
}
@@ -257,12 +476,82 @@ export async function pruneBackupsForServer(serverId: string, keepCount: number)
if (rows.length <= keepCount) return 0
const toDelete = rows.slice(keepCount)
for (const hit of toDelete) {
await db.delete(backupEntries).where(eq(backupEntries.id, hit.id))
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
await deleteBackupRecord(hit.id)
}
return toDelete.length
}
export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) {
try {
const cfg = await getS3ConfigIfEnabled()
if (cfg) {
const client = createS3ClientFromConfig(cfg)
return await s3GetObject(client, cfg.bucket, meta.s3Key)
}
} catch {
/* fallback: локальная копия, если есть */
}
}
return await readFile(path.join(BACKUPS_DIR, meta.filename))
}
export async function restoreBackupToDevice(id: string): Promise<{ filename: string; serverName: string }> {
const meta = await getBackupById(id)
if (!meta) throw new Error("Бэкап не найден")
if (!meta.serverId) throw new Error("Сервер бэкапа удалён — восстановить нельзя")
const row = await getServerRowById(meta.serverId)
if (!row) throw new Error("Сервер не найден")
const content = await readBackupContent(meta)
const client = MikrotikClient.fromServer(row)
const uploaded = await client.uploadTextFile(meta.filename, content.toString("utf8"), 60_000)
await client.importUploadedFile(uploaded)
return { filename: meta.filename, serverName: row.name }
}
export async function syncBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
const cfg = await getS3ConfigIfEnabled()
if (!cfg) throw new Error("S3 не настроен")
const client = createS3ClientFromConfig(cfg)
const objects = await s3ListObjects(client, cfg.bucket, cfg.prefix)
const existing = new Set(
(await db.select({ filename: backupEntries.filename, s3Key: backupEntries.s3Key }).from(backupEntries))
.flatMap((row) => [row.filename, row.s3Key].filter((v): v is string => Boolean(v))),
)
let imported = 0
let skipped = 0
for (const obj of objects) {
if (!obj.key.toLowerCase().endsWith(".rsc")) {
skipped += 1
continue
}
const parsed = parseS3ObjectKey(obj.key)
if (existing.has(obj.key) || existing.has(parsed.filename)) {
skipped += 1
continue
}
const createdAt = obj.lastModified ?? new Date().toISOString()
await db.insert(backupEntries).values({
id: randomUUID(),
serverId: null,
serverName: parsed.serverName,
filename: parsed.filename,
sizeBytes: obj.size,
kind: "auto",
notes: "Импорт из S3",
storage: "s3",
s3Key: obj.key,
s3Etag: null,
uploadError: null,
createdAt,
})
existing.add(obj.key)
existing.add(parsed.filename)
imported += 1
}
return { imported, skipped }
}
export function getBackupsDir(): string {
return BACKUPS_DIR
}
@@ -113,6 +113,15 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
continue
}
const stillOn = await getCertificateRenewSettings()
if (!stillOn.enabled) {
item.action = "skipped"
item.message = "Автообновление выключено"
snapshot.skippedTargets += 1
snapshot.targets?.push(item)
continue
}
const jobId = randomUUID()
await createIssueJobRecord({
id: jobId,
+100
View File
@@ -0,0 +1,100 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { geoipSettings } from "../db/schema.js"
import type { GeoipSettingsDto, GeoipSettingsPatch } from "@mmapp/contracts/geoip"
const SETTINGS_ID = 1
const DEFAULT_INTERVAL_SEC = 604800
let dbEnabled = true
/** Тесты без PostgreSQL: геттеры отдают дефолты, touch/update — no-op. */
export function disableGeoipDbForTests(): void {
dbEnabled = false
}
export function resetGeoipSettingsForTests(): void {
dbEnabled = true
}
type GeoipSettingsRow = typeof geoipSettings.$inferSelect
async function getGeoipSettingsRow(): Promise<GeoipSettingsRow | undefined> {
if (!dbEnabled) return undefined
return (
(await db.select().from(geoipSettings).where(eq(geoipSettings.id, SETTINGS_ID)).limit(1))[0]
)
}
function toDto(row: GeoipSettingsRow | undefined): GeoipSettingsDto {
return {
enabled: row?.enabled ?? true,
updateIntervalSec: row?.updateIntervalSec ?? DEFAULT_INTERVAL_SEC,
lastCheckAt: row?.lastCheckAt ?? null,
lastSuccessAt: row?.lastSuccessAt ?? null,
lastError: row?.lastError ?? null,
countryBuildAt: row?.countryBuildAt ?? null,
asnBuildAt: row?.asnBuildAt ?? null,
updatedAt: row?.updatedAt ?? new Date().toISOString(),
}
}
export async function getGeoipSettings(): Promise<GeoipSettingsDto> {
return toDto(await getGeoipSettingsRow())
}
/** ETag'и зеркала для conditional GET (ключ — имя файла базы). */
export async function getGeoipEtags(): Promise<Record<string, string>> {
const row = await getGeoipSettingsRow()
if (!row) return {}
const raw = row?.etagsJson
if (!raw || typeof raw !== "object") return {}
return Object.fromEntries(
Object.entries(raw as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
}
export async function updateGeoipSettings(patch: GeoipSettingsPatch): Promise<GeoipSettingsDto> {
const prev = await getGeoipSettingsRow()
const next = {
enabled: patch.enabled ?? prev?.enabled ?? true,
updateIntervalSec: patch.updateIntervalSec ?? prev?.updateIntervalSec ?? DEFAULT_INTERVAL_SEC,
updatedAt: new Date().toISOString(),
}
if (prev) {
await db.update(geoipSettings).set(next).where(eq(geoipSettings.id, SETTINGS_ID))
} else {
await db.insert(geoipSettings).values({ id: SETTINGS_ID, ...next })
}
return getGeoipSettings()
}
export async function touchGeoipRunMeta(patch: {
lastCheckAt?: string
lastSuccessAt?: string | null
lastError?: string | null
countryBuildAt?: string | null
asnBuildAt?: string | null
etags?: Record<string, string>
}): Promise<void> {
const prev = await getGeoipSettingsRow()
const set: Partial<typeof geoipSettings.$inferInsert> = {
updatedAt: new Date().toISOString(),
}
if (patch.lastCheckAt !== undefined) set.lastCheckAt = patch.lastCheckAt
if (patch.lastSuccessAt !== undefined) set.lastSuccessAt = patch.lastSuccessAt
if (patch.lastError !== undefined) set.lastError = patch.lastError
if (patch.countryBuildAt !== undefined) set.countryBuildAt = patch.countryBuildAt
if (patch.asnBuildAt !== undefined) set.asnBuildAt = patch.asnBuildAt
if (patch.etags !== undefined) {
const prevEtags = (prev?.etagsJson as Record<string, string> | null) ?? {}
set.etagsJson = { ...prevEtags, ...patch.etags }
}
if (prev) {
await db.update(geoipSettings).set(set).where(eq(geoipSettings.id, SETTINGS_ID))
} else {
await db.insert(geoipSettings).values({ id: SETTINGS_ID, ...set })
}
}
@@ -0,0 +1,198 @@
import { rename, rm, mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { open, type AsnResponse, type CountryResponse } from "maxmind"
import type { GeoipUpdateRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { getGeoipEtags, getGeoipSettings, touchGeoipRunMeta } from "./geoip-settings.js"
import {
GEOIP_ASN_FILE,
GEOIP_COUNTRY_FILE,
geoipDir,
reloadGeoipReaders,
} from "./traffic-flow-geoip.js"
/** Зеркало GeoLite2 без регистрации и ключей (см. README: GeoIP). */
const MIRROR_BASE = "https://github.com/P3TERX/GeoLite.mmdb/raw/download"
const DOWNLOAD_TIMEOUT_MS = 120_000
/** Пробный IP для валидации скачанной базы: Google DNS. */
const PROBE_IP = "8.8.8.8"
type GeoipDbKind = "country" | "asn"
let updating = false
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)
export function getGeoipUpdateState(): { running: boolean } {
return { running: updating }
}
async function validateCountryFile(filePath: string): Promise<string> {
const reader = await open<CountryResponse>(filePath)
const rec = reader.get(PROBE_IP)
const iso = rec?.country?.iso_code ?? rec?.registered_country?.iso_code ?? ""
if (iso !== "US") {
throw new Error(`база Country не распознала ${PROBE_IP} как US (${iso || "нет записи"})`)
}
return reader.metadata.buildEpoch.toISOString()
}
async function validateAsnFile(filePath: string): Promise<string> {
const reader = await open<AsnResponse>(filePath)
const rec = reader.get(PROBE_IP)
const asn = rec?.autonomous_system_number ?? 0
if (asn !== 15169) {
throw new Error(`база ASN не распознала ${PROBE_IP} как AS15169 (${asn ? `AS${asn}` : "нет записи"})`)
}
return reader.metadata.buildEpoch.toISOString()
}
let validateCountry = validateCountryFile
let validateAsn = validateAsnFile
export function setGeoipFetchForTests(fn: typeof fetch): void {
fetchImpl = fn
}
export function setGeoipValidateForTests(opts: {
country?: (filePath: string) => Promise<string>
asn?: (filePath: string) => Promise<string>
}): void {
validateCountry = opts.country ?? validateCountryFile
validateAsn = opts.asn ?? validateAsnFile
}
export function resetGeoipUpdateForTests(): void {
updating = false
fetchImpl = globalThis.fetch.bind(globalThis)
validateCountry = validateCountryFile
validateAsn = validateAsnFile
}
/**
* Разовая проверка/доставка баз с зеркала P3TERX. Conditional GET по ETag
* (304 = не меняем файл), валидация пробоем 8.8.8.8, атомарная подмена через rename.
*/
export async function collectGeoipUpdateOnce(
opts: { force?: boolean } = {},
): Promise<GeoipUpdateRunSnapshot> {
const sampledAt = new Date().toISOString()
if (updating) {
if (opts.force) {
throw Object.assign(new Error("Обновление GeoIP уже выполняется"), { statusCode: 409 })
}
return emptySnapshot(sampledAt, true)
}
const settings = await getGeoipSettings()
if (!settings.enabled && !opts.force) {
return emptySnapshot(sampledAt, true)
}
updating = true
const snapshot: GeoipUpdateRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "geoip_update",
sampledAt,
checked: 0,
downloaded: 0,
skippedUnchanged: 0,
bytes: 0,
errors: [],
}
try {
const dir = geoipDir()
await mkdir(dir, { recursive: true })
const storedEtags = await getGeoipEtags()
const etags: Record<string, string> = {}
const buildAt: Partial<Record<GeoipDbKind, string>> = {}
for (const kind of ["country", "asn"] as const) {
snapshot.checked += 1
const file = kind === "country" ? GEOIP_COUNTRY_FILE : GEOIP_ASN_FILE
const target = path.join(dir, file)
const tmp = `${target}.tmp`
const prevEtag = storedEtags[file]
try {
const ac = new AbortController()
const timer = setTimeout(() => ac.abort(), DOWNLOAD_TIMEOUT_MS)
let res: Response
try {
res = await fetchImpl(`${MIRROR_BASE}/${file}`, {
headers: prevEtag ? { "If-None-Match": prevEtag } : {},
signal: ac.signal,
})
} finally {
clearTimeout(timer)
}
if (res.status === 304) {
snapshot.skippedUnchanged += 1
if (prevEtag) etags[file] = prevEtag
continue
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const etag = res.headers.get("etag") ?? ""
const body = Buffer.from(await res.arrayBuffer())
snapshot.bytes += body.byteLength
await writeFile(tmp, body)
buildAt[kind] =
kind === "country" ? await validateCountry(tmp) : await validateAsn(tmp)
const prevFile = `${target}.prev`
await rm(prevFile, { force: true })
await rename(target, prevFile).catch(() => {
/* текущего файла могло ещё не быть */
})
await rename(tmp, target)
snapshot.downloaded += 1
if (etag) etags[file] = etag
} catch (e) {
await rm(tmp, { force: true }).catch(() => {
/* best-effort */
})
const message = e instanceof Error ? e.message : String(e)
snapshot.errors.push(`${file}: ${message}`)
}
}
if (snapshot.downloaded > 0) {
await reloadGeoipReaders()
}
await touchGeoipRunMeta({
lastCheckAt: sampledAt,
lastSuccessAt: snapshot.errors.length ? null : sampledAt,
lastError: snapshot.errors.length ? snapshot.errors.join("; ") : null,
countryBuildAt: buildAt.country,
asnBuildAt: buildAt.asn,
etags,
})
return snapshot
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.fatalError = message
await touchGeoipRunMeta({
lastCheckAt: sampledAt,
lastError: message,
}).catch(() => {
/* best-effort */
})
return snapshot
} finally {
updating = false
}
}
function emptySnapshot(sampledAt: string, skipped: boolean): GeoipUpdateRunSnapshot {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "geoip_update",
sampledAt,
skipped,
checked: 0,
downloaded: 0,
skippedUnchanged: 0,
bytes: 0,
errors: [],
}
}
+8
View File
@@ -586,6 +586,14 @@ export class MikrotikClient {
: new Error(`Не удалось загрузить файл ${normalized} на RouterOS`)
}
async importUploadedFile(fileName: string): Promise<unknown> {
try {
return await this.post("/import", { "file-name": fileName }, 120_000)
} catch {
return await this.post("/execute", { script: `/import file-name="${fileName}"` }, 120_000)
}
}
async importCertificate(params: {
fileName: string
name: string
@@ -0,0 +1,89 @@
import assert from "node:assert/strict"
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
ListObjectsV2Command,
PutObjectCommand,
type S3Client,
} from "@aws-sdk/client-s3"
import {
buildS3ObjectKey,
normalizeS3Prefix,
parseS3ObjectKey,
sanitizeServerName,
s3DeleteObject,
s3GetObject,
s3ListObjects,
s3PutObject,
s3TestConnection,
} from "./s3-backup-client.js"
assert.equal(normalizeS3Prefix("/mikrotik/backups/"), "mikrotik/backups")
assert.equal(sanitizeServerName("MSK CHR 01"), "MSK_CHR_01")
assert.equal(
buildS3ObjectKey("mikrotik", "msk-chr01", "chr_2026-09-08_03-00-00.rsc"),
"mikrotik/msk-chr01/chr_2026-09-08_03-00-00.rsc",
)
assert.deepEqual(
parseS3ObjectKey("mikrotik/msk-chr01/chr_2026-09-08_03-00-00.rsc"),
{ filename: "chr_2026-09-08_03-00-00.rsc", serverName: "msk-chr01" },
)
const store = new Map<string, Buffer>()
let lastCommand = ""
const fake = {
send: async (command: { input?: Record<string, unknown> }) => {
const name = command.constructor.name
lastCommand = name
const input = command.input ?? {}
if (command instanceof HeadBucketCommand || name === "HeadBucketCommand") {
if (input.Bucket !== "backups") throw new Error("no bucket")
return {}
}
if (command instanceof PutObjectCommand || name === "PutObjectCommand") {
const key = String(input.Key)
const body = input.Body
store.set(key, Buffer.isBuffer(body) ? body : Buffer.from(String(body)))
return { ETag: '"etag-1"' }
}
if (command instanceof GetObjectCommand || name === "GetObjectCommand") {
const key = String(input.Key)
const body = store.get(key)
if (!body) throw new Error("not found")
return { Body: { transformToByteArray: async () => new Uint8Array(body) } }
}
if (command instanceof DeleteObjectCommand || name === "DeleteObjectCommand") {
store.delete(String(input.Key))
return {}
}
if (command instanceof ListObjectsV2Command || name === "ListObjectsV2Command") {
const prefix = String(input.Prefix ?? "")
const contents = [...store.entries()]
.filter(([key]) => !prefix || key.startsWith(prefix))
.map(([key, buf]) => ({ Key: key, Size: buf.length, LastModified: new Date("2026-09-08T00:00:00Z") }))
return { Contents: contents, IsTruncated: false }
}
throw new Error(`unexpected command ${name}`)
},
} as unknown as S3Client
await s3TestConnection(fake, "backups")
assert.equal(lastCommand === "HeadBucketCommand" || lastCommand.includes("Head"), true)
const put = await s3PutObject(fake, "backups", "mikrotik/a/file.rsc", "hello")
assert.equal(put.etag, '"etag-1"')
const got = await s3GetObject(fake, "backups", "mikrotik/a/file.rsc")
assert.equal(got.toString("utf8"), "hello")
const listed = await s3ListObjects(fake, "backups", "mikrotik")
assert.equal(listed.length, 1)
assert.equal(listed[0]?.key, "mikrotik/a/file.rsc")
await s3DeleteObject(fake, "backups", "mikrotik/a/file.rsc")
const after = await s3ListObjects(fake, "backups", "mikrotik")
assert.equal(after.length, 0)
console.log("s3-backup-client.test.ts: ok")
+128
View File
@@ -0,0 +1,128 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
type S3ClientConfig,
} from "@aws-sdk/client-s3"
export type S3BackupConfig = {
endpoint: string
region: string
bucket: string
prefix: string
accessKeyId: string
secretAccessKey: string
forcePathStyle: boolean
}
export type S3ListedObject = {
key: string
size: number
lastModified?: string
}
export function normalizeS3Prefix(prefix: string): string {
return prefix.trim().replace(/^\/+|\/+$/g, "")
}
export function sanitizeServerName(name: string): string {
const safe = name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "")
return safe || "server"
}
export function buildS3ObjectKey(prefix: string, serverSafe: string, filename: string): string {
const parts = [normalizeS3Prefix(prefix), sanitizeServerName(serverSafe), filename]
.filter((part) => part.length > 0)
return parts.join("/")
}
export function parseS3ObjectKey(key: string): { filename: string; serverName: string } {
const parts = key.split("/").filter(Boolean)
const filename = parts.pop() ?? key
const folder = parts.pop() ?? ""
const base = filename.replace(/\.(rsc|backup)$/i, "")
const fromFilename = base.replace(/_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/, "")
return { filename, serverName: folder || fromFilename || filename }
}
export function createS3ClientFromConfig(cfg: S3BackupConfig): S3Client {
const options: S3ClientConfig = {
region: cfg.region.trim() || "us-east-1",
credentials: {
accessKeyId: cfg.accessKeyId,
secretAccessKey: cfg.secretAccessKey,
},
forcePathStyle: cfg.forcePathStyle,
}
const endpoint = cfg.endpoint.trim()
if (endpoint) options.endpoint = endpoint
return new S3Client(options)
}
export async function s3TestConnection(client: S3Client, bucket: string): Promise<void> {
try {
await client.send(new HeadBucketCommand({ Bucket: bucket }))
} catch {
await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 1 }))
}
}
export async function s3PutObject(
client: S3Client,
bucket: string,
key: string,
body: Buffer | string,
): Promise<{ etag?: string }> {
const out = await client.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: "text/plain; charset=utf-8",
}))
return { etag: out.ETag }
}
export async function s3GetObject(
client: S3Client,
bucket: string,
key: string,
): Promise<Buffer> {
const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
const bytes = await out.Body?.transformToByteArray()
if (!bytes) throw new Error("Пустой объект S3")
return Buffer.from(bytes)
}
export async function s3DeleteObject(client: S3Client, bucket: string, key: string): Promise<void> {
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }))
}
export async function s3ListObjects(
client: S3Client,
bucket: string,
prefix: string,
): Promise<S3ListedObject[]> {
const items: S3ListedObject[] = []
let token: string | undefined
const normalized = normalizeS3Prefix(prefix)
do {
const out = await client.send(new ListObjectsV2Command({
Bucket: bucket,
Prefix: normalized ? `${normalized}/` : undefined,
ContinuationToken: token,
}))
for (const obj of out.Contents ?? []) {
if (!obj.Key) continue
items.push({
key: obj.Key,
size: obj.Size ?? 0,
lastModified: obj.LastModified?.toISOString(),
})
}
token = out.IsTruncated ? out.NextContinuationToken : undefined
} while (token)
return items
}
+20
View File
@@ -46,6 +46,8 @@ import { collectCertificatesRenewOnce } from "./certificate-renew-collector.js"
import { getCertificateRenewSettings } from "./certificates-service.js"
import { collectScheduledBackupsOnce } from "./backup-scheduler-collector.js"
import { getBackupScheduleSettings } from "./backup-service.js"
import { collectGeoipUpdateOnce } from "./geoip-update-collector.js"
import { getGeoipSettings } from "./geoip-settings.js"
import {
endSchedulerJob,
isSchedulerJobRunning,
@@ -63,6 +65,7 @@ export const JOB_KEYS = [
"gre_bgp",
"certificates_renew",
"backups",
"geoip_update",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof JOB_KEYS)[number]
@@ -148,6 +151,9 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
case "backups":
snapshot = await collectScheduledBackupsOnce()
break
case "geoip_update":
snapshot = await collectGeoipUpdateOnce()
break
case "alert_engine": {
const r = await runAlertEngineOnce()
snapshot = {
@@ -377,6 +383,18 @@ export async function refreshScheduler(): Promise<void> {
)
}
const geoip = await getGeoipSettings()
if (geoip.enabled) {
const geoipMs = Math.max(6 * 3600_000, geoip.updateIntervalSec * 1000)
void executeSchedulerJob("geoip_update").catch(() => {})
timers.set(
"geoip_update",
setInterval(() => {
void executeSchedulerJob("geoip_update").catch(() => {})
}, geoipMs),
)
}
const alertMs = 20_000
void executeSchedulerJob("alert_engine").catch(() => {})
timers.set(
@@ -409,6 +427,7 @@ export async function getSchedulerStatus() {
const internetPath = await getInternetPathSettings()
const certRenew = await getCertificateRenewSettings()
const backupSchedule = await getBackupScheduleSettings()
const geoip = await getGeoipSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled
@@ -424,6 +443,7 @@ export async function getSchedulerStatus() {
gre_bgp: { enabled: true, intervalSec: 30 },
certificates_renew: { enabled: certRenew.enabled, intervalSec: certRenew.intervalSec },
backups: { enabled: backupSchedule.enabled, intervalSec: 60 },
geoip_update: { enabled: geoip.enabled, intervalSec: geoip.updateIntervalSec },
alert_engine: { enabled: true, intervalSec: 20 },
}
@@ -26,7 +26,8 @@ import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
import { enqueueRipeMisses } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { isIsoCountry } from "./traffic-flow-brands.js"
import { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-planes.js"
@@ -255,7 +256,7 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
peers.add(peer)
const app = applicationName(r.proto, r.dstPort, r.srcPort)
const ripe = lookupRipeCached(peer)
const ripe = resolveFlowIp(peer)
const classified = classifyFlowDst(peer, r.proto, r.dstPort, r.srcPort, ripe)
bump(applications, app, r.bytes, r.packets)
bump(protocols, protoName(r.proto), r.bytes, r.packets)
@@ -1,11 +1,14 @@
import assert from "node:assert/strict"
import {
brandByAsn,
brandByHolder,
countryFromHolder,
isSteamGamePort,
lookupBrand,
OTHER_SERVICE,
isNamedInternetService,
mapServiceNodeId,
resolveFlowBrand,
resolveRipeCountry,
} from "./traffic-flow-brands.js"
@@ -39,4 +42,35 @@ assert.equal(isNamedInternetService("DNS", "DNS"), false)
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
assert.equal(brandByAsn(714)?.service, "Apple")
assert.equal(brandByAsn(714)?.category, "CDN")
assert.equal(brandByAsn(36459)?.service, "GitHub")
assert.equal(brandByAsn(395701)?.service, "Epic")
assert.equal(brandByAsn(6507)?.service, "Riot")
assert.equal(brandByAsn(33353)?.service, "PlayStation")
assert.equal(brandByAsn(14061)?.service, "DigitalOcean")
assert.equal(brandByAsn(24940)?.service, "Hetzner")
assert.equal(brandByAsn(8403)?.service, "Spotify")
assert.equal(brandByAsn(13414)?.service, "X")
assert.equal(brandByAsn(47541)?.service, "VK")
assert.equal(brandByAsn(47764)?.service, "VK")
assert.equal(brandByAsn(30103)?.service, "Zoom")
assert.equal(brandByAsn(19281)?.service, "Quad9")
assert.equal(brandByAsn(9059)?.service, "AWS")
assert.equal(brandByAsn(396982)?.service, "Google")
assert.equal(brandByAsn(400645)?.service, "ChatGPT")
assert.equal(brandByHolder("VALVE-CORPORATION")?.service, "Steam")
assert.equal(brandByHolder("OpenAI, LLC")?.service, "ChatGPT")
assert.equal(brandByHolder("YouTube LLC")?.service, "YouTube")
assert.equal(brandByHolder("AMAZON-AES - Amazon.com, Inc."), null)
assert.equal(isSteamGamePort(17, 27015, 50000), true)
assert.equal(isSteamGamePort(6, 443, 50000), false)
assert.equal(resolveFlowBrand("104.18.35.51", 32590, "VALVE-CORPORATION", 6, 443, 1)?.service, "Cloudflare")
assert.equal(resolveFlowBrand("203.0.113.9", 32590, "", 17, 27015, 50000)?.service, "Steam")
assert.equal(resolveRipeCountry("", 9059, ""), "IE")
assert.equal(resolveRipeCountry("", 24940, ""), "DE")
console.log("traffic-flow-brands.test.ts: ok")
+208 -47
View File
@@ -7,59 +7,162 @@ export interface BrandHit {
category: string
}
const CDN = { category: "CDN" } as const
const WEB = { category: "Веб" } as const
const VIDEO = { category: "Видео / стриминг" } as const
const GAMES = { category: "Игры" } as const
const VOICE = { category: "Голос" } as const
const AI = { category: "ИИ" } as const
const DNS = { category: "DNS" } as const
const CLOUDFLARE: BrandHit = { service: "Cloudflare", ...CDN }
const FASTLY: BrandHit = { service: "Fastly", ...CDN }
const AKAMAI: BrandHit = { service: "Akamai", ...CDN }
const AWS: BrandHit = { service: "AWS", ...CDN }
const MICROSOFT: BrandHit = { service: "Microsoft", ...CDN }
const YANDEX: BrandHit = { service: "Yandex", ...CDN }
const APPLE: BrandHit = { service: "Apple", ...CDN }
const DIGITALOCEAN: BrandHit = { service: "DigitalOcean", ...CDN }
const HETZNER: BrandHit = { service: "Hetzner", ...CDN }
const OVH: BrandHit = { service: "OVH", ...CDN }
const ORACLE: BrandHit = { service: "Oracle", ...CDN }
const LINODE: BrandHit = { service: "Linode", ...CDN }
const VULTR: BrandHit = { service: "Vultr", ...CDN }
const SCALEWAY: BrandHit = { service: "Scaleway", ...CDN }
const IBM_CLOUD: BrandHit = { service: "IBM Cloud", ...CDN }
const ALIBABA: BrandHit = { service: "Alibaba", ...CDN }
const TENCENT: BrandHit = { service: "Tencent", ...CDN }
const GCORE: BrandHit = { service: "G-Core", ...CDN }
const CDN77: BrandHit = { service: "CDN77", ...CDN }
const SELECTEL: BrandHit = { service: "Selectel", ...CDN }
const TIMEWEB: BrandHit = { service: "Timeweb", ...CDN }
const BEGET: BrandHit = { service: "Beget", ...CDN }
const DDOS_GUARD: BrandHit = { service: "DDoS-Guard", ...CDN }
const META: BrandHit = { service: "Meta", ...CDN }
const GOOGLE: BrandHit = { service: "Google", ...WEB }
const GITHUB: BrandHit = { service: "GitHub", ...WEB }
const GITLAB: BrandHit = { service: "GitLab", ...WEB }
const X: BrandHit = { service: "X", ...WEB }
const LINKEDIN: BrandHit = { service: "LinkedIn", ...WEB }
const VK: BrandHit = { service: "VK", ...WEB }
const REDDIT: BrandHit = { service: "Reddit", ...WEB }
const DROPBOX: BrandHit = { service: "Dropbox", ...WEB }
const SNAP: BrandHit = { service: "Snap", ...WEB }
const WIKIPEDIA: BrandHit = { service: "Wikipedia", ...WEB }
const PAYPAL: BrandHit = { service: "PayPal", ...WEB }
const SALESFORCE: BrandHit = { service: "Salesforce", ...WEB }
const YOUTUBE: BrandHit = { service: "YouTube", ...VIDEO }
const NETFLIX: BrandHit = { service: "Netflix", ...VIDEO }
const TWITCH: BrandHit = { service: "Twitch", ...VIDEO }
const TIKTOK: BrandHit = { service: "TikTok", ...VIDEO }
const SPOTIFY: BrandHit = { service: "Spotify", ...VIDEO }
const STEAM: BrandHit = { service: "Steam", ...GAMES }
const BLIZZARD: BrandHit = { service: "Blizzard", ...GAMES }
const EPIC: BrandHit = { service: "Epic", ...GAMES }
const RIOT: BrandHit = { service: "Riot", ...GAMES }
const PLAYSTATION: BrandHit = { service: "PlayStation", ...GAMES }
const ROBLOX: BrandHit = { service: "Roblox", ...GAMES }
const UBISOFT: BrandHit = { service: "Ubisoft", ...GAMES }
const DISCORD: BrandHit = { service: "Discord", ...VOICE }
const TELEGRAM: BrandHit = { service: "Telegram", ...VOICE }
const ZOOM: BrandHit = { service: "Zoom", ...VOICE }
const CHATGPT: BrandHit = { service: "ChatGPT", ...AI }
const QUAD9: BrandHit = { service: "Quad9", ...DNS }
const OPENDNS: BrandHit = { service: "OpenDNS", ...DNS }
function brandEntries(hit: BrandHit, asns: number[]): Array<[number, BrandHit]> {
return asns.map((asn) => [asn, hit])
}
function hqEntries(cc: string, asns: number[]): Array<[number, string]> {
return asns.map((asn) => [asn, cc])
}
const ASN_BRANDS = new Map<number, BrandHit>([
[13335, { service: "Cloudflare", category: "CDN" }],
[209242, { service: "Cloudflare", category: "CDN" }],
[54113, { service: "Fastly", category: "CDN" }],
[20940, { service: "Akamai", category: "CDN" }],
[16509, { service: "AWS", category: "CDN" }],
[14618, { service: "AWS", category: "CDN" }],
[8075, { service: "Microsoft", category: "CDN" }],
[13238, { service: "Yandex", category: "CDN" }],
[32590, { service: "Steam", category: "Игры" }],
[57976, { service: "Blizzard", category: "Игры" }],
[2906, { service: "Netflix", category: "Видео / стриминг" }],
[40027, { service: "Netflix", category: "Видео / стриминг" }],
[15169, { service: "Google", category: "Веб" }],
[36040, { service: "YouTube", category: "Видео / стриминг" }],
[46489, { service: "Twitch", category: "Видео / стриминг" }],
[401115, { service: "ChatGPT", category: "ИИ" }],
[49544, { service: "Discord", category: "Голос" }],
[62041, { service: "Telegram", category: "Голос" }],
[59930, { service: "Telegram", category: "Голос" }],
[211157, { service: "Telegram", category: "Голос" }],
[32934, { service: "Meta", category: "CDN" }],
[396986, { service: "TikTok", category: "Видео / стриминг" }],
...brandEntries(CLOUDFLARE, [13335, 209242]),
...brandEntries(FASTLY, [54113]),
...brandEntries(AKAMAI, [20940, 16625, 32787, 35994, 16702, 24319]),
...brandEntries(AWS, [16509, 14618, 8987, 7224, 9059]),
...brandEntries(MICROSOFT, [8075, 8068, 8069, 8070]),
...brandEntries(YANDEX, [13238]),
...brandEntries(APPLE, [714, 6185]),
...brandEntries(DIGITALOCEAN, [14061]),
...brandEntries(HETZNER, [24940, 213230]),
...brandEntries(OVH, [16276]),
...brandEntries(ORACLE, [31898]),
...brandEntries(LINODE, [63949]),
...brandEntries(VULTR, [20473]),
...brandEntries(SCALEWAY, [12876]),
...brandEntries(IBM_CLOUD, [36351]),
...brandEntries(ALIBABA, [45102]),
...brandEntries(TENCENT, [132203]),
...brandEntries(GCORE, [199524]),
...brandEntries(CDN77, [60068]),
...brandEntries(SELECTEL, [50340, 49505]),
...brandEntries(TIMEWEB, [9123]),
...brandEntries(BEGET, [198610]),
...brandEntries(DDOS_GUARD, [57724]),
...brandEntries(META, [32934, 63293, 54115]),
...brandEntries(GOOGLE, [15169, 396982]),
...brandEntries(GITHUB, [36459]),
...brandEntries(GITLAB, [54876]),
...brandEntries(X, [13414]),
...brandEntries(LINKEDIN, [14413, 40793]),
...brandEntries(VK, [47541, 47764]),
...brandEntries(REDDIT, [394706]),
...brandEntries(DROPBOX, [19679]),
...brandEntries(SNAP, [19750]),
...brandEntries(WIKIPEDIA, [14907]),
...brandEntries(PAYPAL, [17012, 26101]),
...brandEntries(SALESFORCE, [14340]),
...brandEntries(YOUTUBE, [36040, 43515]),
...brandEntries(NETFLIX, [2906, 40027]),
...brandEntries(TWITCH, [46489]),
...brandEntries(TIKTOK, [396986, 138699]),
...brandEntries(SPOTIFY, [8403, 34081]),
...brandEntries(STEAM, [32590]),
...brandEntries(BLIZZARD, [57976]),
...brandEntries(EPIC, [395701]),
...brandEntries(RIOT, [6507, 62830]),
...brandEntries(PLAYSTATION, [33353]),
...brandEntries(ROBLOX, [22697]),
...brandEntries(UBISOFT, [197922]),
...brandEntries(DISCORD, [49544, 394141]),
...brandEntries(TELEGRAM, [62041, 59930, 211157]),
...brandEntries(ZOOM, [30103]),
...brandEntries(CHATGPT, [401115, 400645]),
...brandEntries(QUAD9, [19281]),
...brandEntries(OPENDNS, [36692]),
])
const ASN_HQ_COUNTRY = new Map<number, string>([
[13335, "US"],
[209242, "US"],
[54113, "US"],
[20940, "US"],
[16509, "US"],
[14618, "US"],
[8075, "US"],
[15169, "US"],
[32590, "US"],
[57976, "US"],
[2906, "US"],
[40027, "US"],
[36040, "US"],
[46489, "US"],
[401115, "US"],
[49544, "US"],
[32934, "US"],
[13238, "RU"],
[62041, "NL"],
[59930, "NL"],
[211157, "NL"],
...hqEntries("US", [
13335, 209242, 54113, 20940, 16625, 32787, 35994, 16702, 24319,
16509, 14618, 8987, 7224, 8075, 8068, 8069, 8070, 15169, 396982,
32590, 57976, 2906, 40027, 36040, 43515, 46489, 401115, 400645,
49544, 394141, 32934, 63293, 54115, 714, 6185, 36459, 54876, 14061,
31898, 63949, 20473, 36351, 13414, 14413, 40793, 394706, 19679, 19750,
14907, 17012, 26101, 14340, 30103, 36692, 395701, 6507, 62830, 33353, 22697,
]),
...hqEntries("IE", [9059]),
...hqEntries("SG", [138699]),
...hqEntries("DE", [24940, 213230]),
...hqEntries("FR", [16276, 12876, 197922]),
...hqEntries("CN", [45102, 132203]),
...hqEntries("LU", [199524]),
...hqEntries("CZ", [60068]),
...hqEntries("RU", [13238, 50340, 49505, 9123, 198610, 57724, 47541, 47764]),
...hqEntries("SE", [8403, 34081]),
...hqEntries("NL", [62041, 59930, 211157]),
...hqEntries("CH", [19281]),
])
const GOOGLE: BrandHit = { service: "Google", category: "Веб" }
const CLOUDFLARE: BrandHit = { service: "Cloudflare", category: "CDN" }
const YOUTUBE: BrandHit = { service: "YouTube", category: "Видео / стриминг" }
const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: CLOUDFLARE },
@@ -75,8 +178,25 @@ const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
].sort((a, b) => b.prefixLen - a.prefixLen)
const HOLDER_BRANDS: Array<{ re: RegExp; hit: BrandHit }> = [
{ re: /youtube/i, hit: YOUTUBE },
{ re: /valve|\bsteam\b/i, hit: STEAM },
{ re: /blizzard|battle.?net/i, hit: BLIZZARD },
{ re: /openai/i, hit: CHATGPT },
{ re: /riot games/i, hit: RIOT },
{ re: /epic games/i, hit: EPIC },
{ re: /\bapple\b/i, hit: APPLE },
{ re: /github/i, hit: GITHUB },
{ re: /spotify/i, hit: SPOTIFY },
{ re: /twitter|\bx corp\b/i, hit: X },
{ re: /dropbox/i, hit: DROPBOX },
{ re: /akamai/i, hit: AKAMAI },
]
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
const STEAM_ASN = 32590
export function isIsoCountry(code: string): boolean {
const c = String(code ?? "").trim().toUpperCase()
return /^[A-Z]{2}$/.test(c) && !NON_ISO.has(c)
@@ -114,10 +234,51 @@ export function brandByCidr(ip: string): BrandHit | null {
return null
}
export function brandByHolder(holder: string): BrandHit | null {
const h = String(holder ?? "").trim()
if (!h) return null
for (const row of HOLDER_BRANDS) {
if (row.re.test(h)) return row.hit
}
return null
}
/** Игровые порты Steam — только вместе с AS32590, никогда :80/:443. */
export function isSteamGamePort(proto: number, dstPort: number, srcPort: number): boolean {
if (proto !== 6 && proto !== 17) return false
const port = dstPort || srcPort
if (!port || port === 80 || port === 443) return false
if (port === 4380 || port === 3478) return true
return port >= 27000 && port <= 27100
}
export function lookupBrand(ip: string, asn: number): BrandHit | null {
return brandByCidr(ip) || brandByAsn(asn)
}
/**
* Cloudflare CIDR бьёт holder (витрина на CF не становится Steam).
* Holder (YouTube и др.) бьёт остальные CIDR/ASN.
* Порты Steam только AS32590 и не выше Cloudflare CIDR.
*/
export function resolveFlowBrand(
ip: string,
asn: number,
holder: string,
proto = 0,
dstPort = 0,
srcPort = 0,
): BrandHit | null {
const cidrBrand = brandByCidr(ip)
if (cidrBrand?.service === "Cloudflare") return cidrBrand
const holderBrand = brandByHolder(holder)
if (holderBrand) return holderBrand
const fromLookup = cidrBrand || brandByAsn(asn)
if (fromLookup) return fromLookup
if (asn === STEAM_ASN && isSteamGamePort(proto, dstPort, srcPort)) return STEAM
return null
}
const SKIP_MAP_SERVICES = new Set([
OTHER_SERVICE,
"GRE",
@@ -53,6 +53,71 @@ const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, {
assert.equal(youtube.service, "YouTube")
assert.equal(youtube.category, "Видео / стриминг")
const valve = classifyFlowDst("203.0.113.40", 17, 27015, 50000, {
prefix: "203.0.113.0/24",
asn: 64501,
country: "US",
lat: null,
lng: null,
holder: "VALVE-CORPORATION",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(valve.service, "Steam")
assert.equal(valve.category, "Игры")
const openaiHolder = classifyFlowDst("203.0.113.41", 6, 443, 1, {
prefix: "203.0.113.0/24",
asn: 64502,
country: "US",
lat: null,
lng: null,
holder: "OPENAI, US",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(openaiHolder.service, "ChatGPT")
assert.equal(openaiHolder.category, "ИИ")
const cfNotSteam = classifyFlowDst("104.18.35.51", 6, 443, 1, {
prefix: "104.18.0.0/16",
asn: 32590,
country: "US",
lat: null,
lng: null,
holder: "VALVE-CORPORATION",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(cfNotSteam.service, "Cloudflare")
assert.notEqual(cfNotSteam.service, "Steam")
const awsIeu = classifyFlowDst("203.0.113.42", 6, 443, 1, {
prefix: "203.0.113.0/24",
asn: 9059,
country: "IE",
lat: null,
lng: null,
holder: "AMAZON-02",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(awsIeu.service, "AWS")
assert.equal(awsIeu.category, "CDN")
const googleCloud = classifyFlowDst("203.0.113.43", 6, 443, 1, {
prefix: "203.0.113.0/24",
asn: 396982,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE-CLOUD",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(googleCloud.service, "Google")
assert.equal(googleCloud.category, "Веб")
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
assert.equal(gre.service, "GRE")
assert.equal(gre.category, "Туннель")
@@ -1,4 +1,4 @@
import { lookupBrand, OTHER_SERVICE } from "./traffic-flow-brands.js"
import { OTHER_SERVICE, resolveFlowBrand } from "./traffic-flow-brands.js"
import { db } from "../db/index.js"
import { evobgpSettings } from "../db/schema.js"
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.js"
@@ -47,12 +47,13 @@ export function seedFlowCatalogForTests(input: {
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
const p = purpose.toLowerCase()
if (/gaming|steam|epic|riot/.test(p)) return "Игры"
if (/streaming|youtube|netflix|twitch|video/.test(p)) return "Видео / стриминг"
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
if (/gaming|steam|epic|riot|playstation|roblox|ubisoft/.test(p)) return "Игры"
if (/streaming|youtube|netflix|twitch|video|spotify/.test(p)) return "Видео / стриминг"
if (/cdn|cloudflare|akamai|fastly|hetzner|ovh|apple/.test(p)) return "CDN"
if (/voip|discord|zoom/.test(p)) return "Голос"
if (/openai|chatgpt|\bai\b/.test(p)) return "ИИ"
if (/веб|web|google/.test(p)) return "Веб"
if (/quad9|opendns/.test(p)) return "DNS"
if (/веб|web|google|github|paypal|vk|linkedin/.test(p)) return "Веб"
const app = applicationName(proto, dstPort, srcPort)
if (app === "DNS" || app === "SSH" || app === "BGP") return app
if (app === "GRE" || app === "ESP" || app === "WireGuard") return "Туннель"
@@ -79,10 +80,7 @@ export function classifyFlowDst(
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
const hit = matchCidr(dst)
const holder = ripe?.holder ?? ""
const youtubeHolder = /youtube/i.test(holder)
const brand = youtubeHolder
? { service: "YouTube", category: "Видео / стриминг" }
: lookupBrand(dst, ripe?.asn ?? 0)
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, holder, proto, dstPort, srcPort)
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
const category = hit
+25 -2
View File
@@ -5,7 +5,8 @@ import { classifyFlowPlaneLite } from "./traffic-flow-planes.js"
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
import { applicationName } from "./traffic-flow-apps.js"
import { classifyFlowDst } from "./traffic-flow-classify.js"
import { enqueueRipeMisses, lookupRipeCached, pruneRipeSqlite } from "./traffic-flow-ripe.js"
import { enqueueRipeMisses, pruneRipeSqlite } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
import { isIsoCountry } from "./traffic-flow-brands.js"
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
@@ -333,7 +334,7 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
addToTick(serverId, flow, flow.bytes)
bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets)
const peer = pickInternetPeer(flow.src, flow.dst, flow.srcPort, flow.dstPort)
const ripe = lookupRipeCached(peer)
const ripe = resolveFlowIp(peer)
if (peer && !ripe) ripeMisses.push(peer)
const classified = classifyFlowDst(peer, flow.proto, flow.dstPort, flow.srcPort, ripe)
const app = applicationName(flow.proto, flow.dstPort, flow.srcPort)
@@ -939,3 +940,25 @@ export function pendingSizeForTests(): number {
export function droppedForTests(): number {
return dropped
}
/** Снимок минутных dims (dim → key → bytes) для тестов обогащения потоков. */
export function minuteDimsSnapshotForTests(): Map<string, Map<string, { bytes: number; packets: number }>> {
const out = new Map<string, Map<string, { bytes: number; packets: number }>>()
for (const [k, acc] of minuteDims) {
// dimKey: serverId\0bucketAt\0dim\0key
const parts = k.split("\0")
const dim = parts[2] ?? ""
const key = parts.slice(3).join("\0")
let byKey = out.get(dim)
if (!byKey) {
byKey = new Map()
out.set(dim, byKey)
}
const prev = byKey.get(key)
byKey.set(key, {
bytes: (prev?.bytes ?? 0) + acc.bytes,
packets: (prev?.packets ?? 0) + acc.packets,
})
}
return out
}
@@ -0,0 +1,186 @@
import assert from "node:assert/strict"
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import type { AsnResponse, CountryResponse, Reader } from "maxmind"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
import {
lookupGeoip,
resetGeoipForTests,
resolveFlowIp,
setGeoipReadersForTests,
} from "./traffic-flow-geoip.js"
import {
resetEngineForTests,
ingestParsedFlowsForServerForTests,
minuteDimsSnapshotForTests,
} from "./traffic-flow-engine.js"
import { classifyFlowDst } from "./traffic-flow-classify.js"
import { disableGeoipDbForTests } from "./geoip-settings.js"
import {
collectGeoipUpdateOnce,
resetGeoipUpdateForTests,
setGeoipFetchForTests,
setGeoipValidateForTests,
} from "./geoip-update-collector.js"
disableRipePersistForTests()
disableRipeEnqueueForTests()
resetRipeCacheForTests()
resetGeoipForTests()
// ── lookupGeoip: приватные IP → negative без ридеров ──────────────────────────
assert.equal(lookupGeoip("10.1.1.8")?.ok, false)
assert.equal(lookupGeoip("192.168.0.1")?.prefix, "192.168.0.1/32")
assert.equal(lookupGeoip("100.64.1.2")?.ok, false)
assert.equal(lookupGeoip("fe80::1")?.prefix, "fe80::1/128")
// ── без ридеров публичный IP → null, resolveFlowIp уходит в RIPE-кэш ─────────
assert.equal(lookupGeoip("1.2.3.10"), null)
seedRipeCacheForTests({
prefix: "1.2.3.0/24",
asn: 64500,
country: "NL",
lat: null,
lng: null,
holder: "TEST",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(resolveFlowIp("1.2.3.10")?.country, "NL")
assert.equal(resolveFlowIp("1.2.3.10")?.asn, 64500)
// ── fake-ридеры: geoip приоритетнее RIPE ──────────────────────────────────────
function fakeCountryReader(byIp: Record<string, string>): Reader<CountryResponse> {
return {
get(ip: string) {
const iso = byIp[ip]
return iso ? ({ country: { iso_code: iso } } as CountryResponse) : null
},
metadata: { buildEpoch: new Date("2026-09-02T00:00:00Z") },
} as unknown as Reader<CountryResponse>
}
function fakeAsnReader(byIp: Record<string, { asn: number; org: string }>): Reader<AsnResponse> {
return {
get(ip: string) {
const hit = byIp[ip]
return hit
? ({ autonomous_system_number: hit.asn, autonomous_system_organization: hit.org } as AsnResponse)
: null
},
metadata: { buildEpoch: new Date("2026-09-02T00:00:00Z") },
} as unknown as Reader<AsnResponse>
}
setGeoipReadersForTests({
country: fakeCountryReader({ "8.8.8.8": "US", "6.6.6.6": "EU" }),
asn: fakeAsnReader({
"8.8.8.8": { asn: 15169, org: "GOOGLE" },
"6.6.6.6": { asn: 15169, org: "GOOGLE" },
}),
})
const hit = resolveFlowIp("8.8.8.8")
assert.equal(hit?.country, "US")
assert.equal(hit?.asn, 15169)
assert.equal(hit?.holder, "GOOGLE")
assert.equal(hit?.ok, true)
// 1.2.3.10 в fake-ридерах нет — по-прежнему из RIPE-кэша
assert.equal(resolveFlowIp("1.2.3.10")?.asn, 64500)
// EU не ISO-страна: отфильтрована, страна выведена из ASN (HQ Google → US)
assert.equal(lookupGeoip("6.6.6.6")?.country, "US")
// geoip-мета совместима с classifyFlowDst (бренд по ASN 15169)
const classified = classifyFlowDst("8.8.8.8", 6, 443, 51504, hit)
assert.equal(classified.service, "Google")
// ── движок: dims country/asn наполняются из geoip-ридеров ────────────────────
resetEngineForTests()
ingestParsedFlowsForServerForTests(1, [{
src: "192.168.88.10",
dst: "8.8.8.8",
proto: 6,
srcPort: 51504,
dstPort: 443,
bytes: 1000,
packets: 10,
inIface: "wg-flow",
outIface: "",
nextHop: "",
flowStartMs: 0,
flowEndMs: 0,
natSrc: "",
natDst: "",
}])
const dims = minuteDimsSnapshotForTests()
assert.equal(dims.get("country")?.get("US")?.bytes, 1000)
assert.equal(dims.get("asn")?.get("15169")?.bytes, 1000)
// ── коллектор: 304 → обе базы без изменений ───────────────────────────────────
disableGeoipDbForTests()
resetGeoipUpdateForTests()
const geoipDir = mkdtempSync(path.join(tmpdir(), "mm-geoip-test-"))
process.env.GEOIP_DIR = geoipDir
setGeoipFetchForTests(async () => new Response(null, { status: 304 }))
let snap = await collectGeoipUpdateOnce({ force: true })
assert.equal(snap.skippedUnchanged, 2)
assert.equal(snap.downloaded, 0)
assert.equal(existsSync(path.join(geoipDir, "GeoLite2-Country.mmdb")), false)
// ── коллектор: 200 + валидация ok → подмена, старый файл в .prev ─────────────
const countryPath = path.join(geoipDir, "GeoLite2-Country.mmdb")
const asnPath = path.join(geoipDir, "GeoLite2-ASN.mmdb")
writeFileSync(countryPath, "old-country")
setGeoipFetchForTests(async () =>
new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { etag: '"v1"' } }))
setGeoipValidateForTests({
country: async (p) => {
assert.ok(p.endsWith(".tmp"), "валидация должна идти по tmp-файлу")
return "2026-09-08T00:00:00.000Z"
},
asn: async () => "2026-09-08T00:00:00.000Z",
})
snap = await collectGeoipUpdateOnce({ force: true })
assert.equal(snap.downloaded, 2)
assert.equal(snap.errors.length, 0)
assert.deepEqual(readFileSync(countryPath), Buffer.from([1, 2, 3]))
assert.equal(readFileSync(`${countryPath}.prev`, "utf8"), "old-country")
assert.equal(existsSync(`${asnPath}.prev`), false, "prev у asn не бывает при первой загрузке")
assert.equal(existsSync(`${countryPath}.tmp`), false)
// ── коллектор: битая база → подмены нет, старый файл цел, tmp удалён ─────────
writeFileSync(asnPath, "good-asn")
setGeoipFetchForTests(async () =>
new Response(new Uint8Array([9, 9]), { status: 200 }))
setGeoipValidateForTests({
country: async () => {
throw new Error("битая база")
},
asn: async () => {
throw new Error("битая база")
},
})
snap = await collectGeoipUpdateOnce({ force: true })
assert.equal(snap.downloaded, 0)
assert.equal(snap.errors.length, 2)
assert.deepEqual(readFileSync(countryPath), Buffer.from([1, 2, 3]), "country не тронута")
assert.equal(readFileSync(asnPath, "utf8"), "good-asn", "asn не тронут")
assert.equal(existsSync(`${countryPath}.tmp`), false)
assert.equal(existsSync(`${asnPath}.tmp`), false)
rmSync(geoipDir, { recursive: true, force: true })
delete process.env.GEOIP_DIR
resetGeoipUpdateForTests()
resetGeoipForTests()
console.log("traffic-flow-geoip.test.ts: ok")
+162
View File
@@ -0,0 +1,162 @@
import { existsSync } from "node:fs"
import path from "node:path"
import { open, type AsnResponse, type CountryResponse, type Reader } from "maxmind"
import { isNonPublicIp } from "./traffic-flow-ip.js"
import { isIsoCountry, resolveRipeCountry } from "./traffic-flow-brands.js"
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
export const GEOIP_COUNTRY_FILE = "GeoLite2-Country.mmdb"
export const GEOIP_ASN_FILE = "GeoLite2-ASN.mmdb"
/** Каталог баз: `storage/geoip` рядом со storage/backups; переопределяется GEOIP_DIR. */
export function geoipDir(): string {
return path.resolve(process.env.GEOIP_DIR ?? path.join(process.cwd(), "storage", "geoip"))
}
export function geoipCountryPath(): string {
return path.join(geoipDir(), GEOIP_COUNTRY_FILE)
}
export function geoipAsnPath(): string {
return path.join(geoipDir(), GEOIP_ASN_FILE)
}
export interface GeoipReaders {
country: Reader<CountryResponse> | null
asn: Reader<AsnResponse> | null
}
let readers: GeoipReaders = { country: null, asn: null }
let initPromise: Promise<GeoipReaders> | null = null
/** Открывает оба файла best-effort: отсутствующий/битый файл не мешает второму. */
export async function openGeoipReaders(dir = geoipDir()): Promise<GeoipReaders> {
const next: GeoipReaders = { country: null, asn: null }
if (existsSync(path.join(dir, GEOIP_COUNTRY_FILE))) {
try {
next.country = await open<CountryResponse>(path.join(dir, GEOIP_COUNTRY_FILE))
} catch {
/* битый файл — работаем без country */
}
}
if (existsSync(path.join(dir, GEOIP_ASN_FILE))) {
try {
next.asn = await open<AsnResponse>(path.join(dir, GEOIP_ASN_FILE))
} catch {
/* битый файл — работаем без ASN */
}
}
return next
}
/** Открывает ридеры при старте; файлы есть — работают, нет — lookup уходит в RIPE-fallback. */
export async function initGeoip(): Promise<GeoipReaders> {
if (!initPromise) {
initPromise = openGeoipReaders().then((next) => {
readers = next
return next
})
}
return initPromise
}
/** Переоткрывает ридеры после обновления файлов (атомарная замена ссылок). */
export async function reloadGeoipReaders(): Promise<GeoipReaders> {
const next = await openGeoipReaders()
readers = next
initPromise = Promise.resolve(next)
return next
}
export function setGeoipReadersForTests(next: Partial<GeoipReaders>): void {
readers = { country: next.country ?? null, asn: next.asn ?? null }
}
export function resetGeoipForTests(): void {
readers = { country: null, asn: null }
initPromise = null
}
function negativeMeta(ip: string): FlowIpMeta {
const v6 = ip.includes(":")
return {
prefix: `${ip}/${v6 ? 128 : 32}`,
asn: 0,
country: "—",
lat: null,
lng: null,
holder: "",
ok: false,
fetchedAt: Date.now(),
}
}
function safeCountryIso(reader: Reader<CountryResponse>, ip: string): string {
try {
const rec = reader.get(ip)
return rec?.country?.iso_code ?? rec?.registered_country?.iso_code ?? ""
} catch {
return ""
}
}
function safeAsn(reader: Reader<AsnResponse>, ip: string): { asn: number; holder: string } {
try {
const rec = reader.get(ip)
return {
asn: rec?.autonomous_system_number ?? 0,
holder: rec?.autonomous_system_organization ?? "",
}
} catch {
return { asn: 0, holder: "" }
}
}
/**
* Синхронный lookup по локальным GeoLite2. Возвращает FlowIpMeta в семантике RIPE-кэша
* (ok=true когда есть страна или ASN; null данных нет, пусть пробует RIPE).
*/
export function lookupGeoip(ip: string): FlowIpMeta | null {
const trimmed = String(ip ?? "").trim()
if (!trimmed) return null
if (isNonPublicIp(trimmed)) return negativeMeta(trimmed)
const { country: countryReader, asn: asnReader } = readers
if (!countryReader && !asnReader) return null
const iso = countryReader ? safeCountryIso(countryReader, trimmed) : ""
const country = iso && isIsoCountry(iso) ? iso : ""
const { asn, holder } = asnReader ? safeAsn(asnReader, trimmed) : { asn: 0, holder: "" }
if (!asn && !country) return null
return {
prefix: `${trimmed}/${trimmed.includes(":") ? 128 : 32}`,
asn,
country: resolveRipeCountry(country, asn, holder) || "—",
lat: null,
lng: null,
holder,
ok: true,
fetchedAt: Date.now(),
}
}
/** Главный вход для потребителей пайплайна: локальные базы первыми, RIPE-кэш fallback. */
export function resolveFlowIp(ip: string): FlowIpMeta | null {
return lookupGeoip(ip) ?? lookupRipeCached(ip)
}
export interface GeoipReadersStatus {
countryLoaded: boolean
asnLoaded: boolean
countryBuildAt: string | null
asnBuildAt: string | null
dir: string
}
export function geoipReadersStatus(): GeoipReadersStatus {
return {
countryLoaded: Boolean(readers.country),
asnLoaded: Boolean(readers.asn),
countryBuildAt: readers.country?.metadata.buildEpoch.toISOString() ?? null,
asnBuildAt: readers.asn?.metadata.buildEpoch.toISOString() ?? null,
dir: geoipDir(),
}
}
@@ -4,7 +4,13 @@ import {
ingestParsedFlowsForServerForTests,
resetFlowRingsForTests,
} from "./traffic-flow-ingest.js"
import { buildFlowMapHops, resetFlowMapHopsCacheForTests } from "./traffic-flow-map-hops.js"
import {
buildFlowMapHops,
MAP_SERVICE_MIN_NODES,
MAP_SERVICE_NODE_CAP,
pickMapServices,
resetFlowMapHopsCacheForTests,
} from "./traffic-flow-map-hops.js"
import { withPgOrSkip } from "../test/pg.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
@@ -15,6 +21,55 @@ import {
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
{
const googleOnly = pickMapServices(
[{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 1 }],
5,
)
assert.equal(googleOnly.length, 1)
assert.equal(googleOnly[0]?.share, 1)
const twoNamed = pickMapServices(
[
{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 0.5 },
{ id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 400, bps: 0, share: 0.5 },
],
5,
)
assert.equal(twoNamed.length, 2)
const tinyTail = pickMapServices(
[
{ id: "svc:google", label: "Google", category: "Веб", bytes: 9000, bps: 0, share: 0.9 },
...Array.from({ length: 9 }, (_, i) => ({
id: `svc:t${i}`,
label: `T${i}`,
category: "Веб",
bytes: 100,
bps: 0,
share: 0.01,
})),
],
5,
)
assert.equal(tinyTail.length, MAP_SERVICE_MIN_NODES)
assert.equal(tinyTail.at(-1)?.id, "svc:t6")
const allOff = pickMapServices(
Array.from({ length: 25 }, (_, i) => ({
id: `svc:n${i}`,
label: `N${i}`,
category: "Веб",
bytes: 25 - i,
bps: 0,
share: 0.04,
})),
0,
)
assert.equal(allOff.length, MAP_SERVICE_NODE_CAP)
console.log("traffic-flow-map-hops.test.ts: pickMapServices ok")
}
if (!(await withPgOrSkip())) {
console.log("traffic-flow-map-hops.test.ts: skip")
process.exit(0)
@@ -178,6 +233,19 @@ function googleRipe() {
})
}
function seedRipeAsn(ip: string, asn: number, holder: string) {
seedRipeCacheForTests({
prefix: `${ip}/32`,
asn,
country: "US",
lat: 37.4,
lng: -122.1,
holder,
ok: true,
fetchedAt: Date.now(),
})
}
function payloadFlow(dst: string, bytes: number) {
return {
src: "10.100.1.17",
@@ -241,10 +309,106 @@ try {
resetFlowMapHopsCacheForTests()
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(four.totalBytes, 10_000)
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
const googleFour = four.services?.find((s) => s.id === "svc:google")
assert.ok(googleFour, "единственный бренд виден при 4% от окна")
assert.ok(googleFour.share >= 0.99, "доля среди брендов ≈ 1")
resetFlowMapHopsCacheForTests()
const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google 4%")
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 400),
payloadFlow("104.18.35.51", 400),
payloadFlow("203.0.113.50", 9200),
])
try {
resetFlowMapHopsCacheForTests()
const two = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const googleTwo = two.services?.find((s) => s.id === "svc:google")
const cfTwo = two.services?.find((s) => s.id === "svc:cloudflare")
assert.ok(googleTwo, "Google среди брендов")
assert.ok(cfTwo, "Cloudflare среди брендов")
assert.ok(googleTwo.share >= 0.05)
assert.ok(cfTwo.share >= 0.05)
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
seedRipeAsn("162.254.192.71", 32590, "VALVE-CORP")
ingestParsedFlowsForServerForTests(7, [
payloadFlow("162.254.192.71", 2000),
])
try {
resetFlowMapHopsCacheForTests()
const steam = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.ok(steam.services?.some((s) => s.id === "svc:steam"), "Steam AS32590 на карте")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
const smallBrands: Array<{ ip: string; asn: number; holder: string; bytes: number; id: string }> = [
{ ip: "203.0.113.1", asn: 714, holder: "APPLE-ENGINEERING", bytes: 400, id: "svc:apple" },
{ ip: "203.0.113.2", asn: 36459, holder: "GITHUB", bytes: 390, id: "svc:github" },
{ ip: "203.0.113.3", asn: 54876, holder: "GITLAB", bytes: 380, id: "svc:gitlab" },
{ ip: "203.0.113.4", asn: 8403, holder: "SPOTIFY", bytes: 370, id: "svc:spotify" },
{ ip: "203.0.113.5", asn: 13414, holder: "TWITTER", bytes: 360, id: "svc:x" },
{ ip: "203.0.113.6", asn: 47541, holder: "VKONTAKTE", bytes: 350, id: "svc:vk" },
{ ip: "203.0.113.7", asn: 30103, holder: "ZOOM", bytes: 340, id: "svc:zoom" },
{ ip: "203.0.113.8", asn: 395701, holder: "EPIC-GAMES", bytes: 330, id: "svc:epic" },
{ ip: "203.0.113.9", asn: 6507, holder: "RIOT-GAMES", bytes: 320, id: "svc:riot" },
]
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
for (const b of smallBrands) seedRipeAsn(b.ip, b.asn, b.holder)
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 5000),
...smallBrands.map((b) => payloadFlow(b.ip, b.bytes)),
])
try {
resetFlowMapHopsCacheForTests()
const top = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const ids = new Set((top.services ?? []).map((s) => s.id))
assert.equal(top.services?.length, MAP_SERVICE_MIN_NODES, "топ-8 брендов на карте")
assert.ok(ids.has("svc:google"))
for (const b of smallBrands.slice(0, 7)) assert.ok(ids.has(b.id), b.id)
assert.ok(!ids.has("svc:epic"), "хвост ниже ранга 8 скрыт")
assert.ok(!ids.has("svc:riot"))
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
+30 -22
View File
@@ -5,21 +5,24 @@ import { userInterfaceBindings } from "../db/schema.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import {
isNamedInternetService,
lookupBrand,
mapServiceNodeId,
resolveFlowBrand,
} from "./traffic-flow-brands.js"
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
import { pickInternetPeer } from "./traffic-flow-ip.js"
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
import { type FlowIpMeta } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog } from "./traffic-flow-topology.js"
import { flowDataEpoch } from "./traffic-flow-engine.js"
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
export const MAP_SERVICE_NODE_CAP = 20
/** Минимум узлов-брендов на карте, даже если доля ниже порога. */
export const MAP_SERVICE_MIN_NODES = 8
const HOPS_CACHE_TTL_MS = 2000
export interface FlowMapHopsQuery {
@@ -99,6 +102,15 @@ export function clampMapServiceMinSharePct(n: unknown): number {
return Math.min(100, Math.max(0, v))
}
/** Доля среди именованных брендов; порог ИЛИ топ-N, затем cap. */
export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] {
if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP)
const minShare = minSharePct / 100
return ranked
.filter((s, i) => s.share >= minShare || i < MAP_SERVICE_MIN_NODES)
.slice(0, MAP_SERVICE_NODE_CAP)
}
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
return JSON.stringify({
epoch: flowDataEpoch(),
@@ -174,10 +186,7 @@ function classifyMapDstLite(
if (proto === 47 || proto === 50) return null
const app = applicationName(proto, dstPort, srcPort)
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
if (/youtube/i.test(ripe?.holder ?? "")) {
return { service: "YouTube", category: "Видео / стриминг" }
}
const brand = lookupBrand(dst, ripe?.asn ?? 0)
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
return brand
}
@@ -386,7 +395,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
}
for (const [dst, acc] of dstAcc) {
const ripe = lookupRipeCached(dst)
const ripe = resolveFlowIp(dst)
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
if (!classified) continue
const toId = mapServiceNodeId(classified.service)
@@ -439,21 +448,20 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
}
}
const minShare = minSharePct / 100
let services: FlowMapService[] = [...svcTotals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
}))
.sort((a, b) => b.bytes - a.bytes)
if (minSharePct > 0) {
services = services.filter((s) => s.share >= minShare)
}
services = services.slice(0, MAP_SERVICE_NODE_CAP)
const namedBytes = [...svcTotals.values()].reduce((n, s) => n + s.bytes, 0)
const services = pickMapServices(
[...svcTotals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: namedBytes > 0 ? s.bytes / namedBytes : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
minSharePct,
)
const keepSvc = new Set(services.map((s) => s.id))
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
.filter((e) => keepSvc.has(e.toId))
@@ -226,6 +226,19 @@ export interface BackupsRunSnapshot {
fatalError?: string
}
export interface GeoipUpdateRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "geoip_update"
sampledAt: string
skipped?: boolean
fatalError?: string
checked: number
downloaded: number
skippedUnchanged: number
bytes: number
errors: string[]
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
@@ -236,4 +249,5 @@ export type SchedulerRunSnapshot =
| InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| GeoipUpdateRunSnapshot
| AlertEngineRunSnapshot
@@ -0,0 +1,97 @@
"use client"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import type { Backup } from "@/lib/data"
import { AlertCircleIcon, LoaderCircleIcon, TriangleAlertIcon } from "lucide-react"
export function BackupDeleteDialog({
backup,
busy,
onConfirm,
onCancel,
}: {
backup: Backup | null
busy: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<AlertCircleIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить бэкап?</AlertDialogTitle>
<AlertDialogDescription>
Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет удалён
{backup?.storage === "s3" || backup?.storage === "both" ? " локально и из S3" : " с диска"}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={busy} onClick={onConfirm}>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function BackupRestoreDialog({
backup,
busy,
onConfirm,
onCancel,
}: {
backup: Backup | null
busy: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-warning/10 text-warning">
<TriangleAlertIcon />
</AlertDialogMedia>
<AlertDialogTitle>Восстановить конфигурацию?</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-3">
<span>
Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет загружен
на <span className="text-foreground">{backup?.server}</span> и импортирован.
</span>
<Alert variant="warning">
<TriangleAlertIcon />
<AlertTitle>Это изменит рабочую конфигурацию роутера</AlertTitle>
<AlertDescription>
Сессия может оборваться. Убедитесь, что выбран именно этот сервер и этот снимок.
</AlertDescription>
</Alert>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction disabled={busy} onClick={onConfirm}>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Восстановить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+191
View File
@@ -0,0 +1,191 @@
"use client"
import type { Server } from "@/lib/data"
import { FormField } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetFooter,
SheetClose,
} from "@/components/ui/sheet"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { cn } from "@/lib/utils"
export function BackupCreateSheet({
open,
onOpenChange,
step,
onStepChange,
servers,
selected,
onToggle,
onSelectAll,
onClear,
notes,
onNotesChange,
destinationLabel,
busy,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
step: number
onStepChange: (step: number) => void
servers: Server[]
selected: Set<string>
onToggle: (id: string) => void
onSelectAll: () => void
onClear: () => void
notes: string
onNotesChange: (value: string) => void
destinationLabel: string
busy: boolean
onSubmit: () => void
}) {
return (
<Sheet open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) onStepChange(1) }}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый бэкап</SheetTitle>
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
</SheetHeader>
<Stepper value={step} onValueChange={onStepChange} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Серверы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">Заметка</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">Подтверждение</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-3">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium">Выберите серверы</p>
<div className="flex items-center gap-2">
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
Все
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Сбросить
</button>
</div>
</div>
{servers.map((s) => {
const checked = selected.has(s.id)
return (
<label
key={s.id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40",
)}
>
<Checkbox
checked={checked}
onCheckedChange={() => onToggle(s.id)}
aria-label={`Выбрать ${s.name}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
<StatusBadge status={s.status} />
</div>
</div>
{s.status === "offline" && (
<span className="text-xs text-muted-foreground">недоступен</span>
)}
</label>
)
})}
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<FormField label="Заметка">
<Input
placeholder="Например: перед обновлением BGP"
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
/>
</FormField>
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">
Будет создан бэкап для <strong className="text-foreground">{selected.size}</strong> серверов.
</p>
<p className="text-muted-foreground">
Куда сохранится: <strong className="text-foreground">{destinationLabel}</strong>
</p>
{notes ? <p className="text-muted-foreground">Заметка: {notes}</p> : null}
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
{step > 1 && (
<Button type="button" variant="outline" className="flex-1" onClick={() => onStepChange(step - 1)}>
Назад
</Button>
)}
{step < 3 ? (
<Button
type="button"
className="flex-1"
disabled={step === 1 && selected.size === 0}
onClick={() => onStepChange(step + 1)}
>
Далее
</Button>
) : (
<Button
type="button"
className="flex-1"
disabled={selected.size === 0 || busy}
onClick={onSubmit}
>
Снять бэкап ({selected.size})
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+97
View File
@@ -0,0 +1,97 @@
"use client"
import type { Filter } from "@/components/reui/filters"
import type { Backup } from "@/lib/data"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import {
BACKUP_FILTER_ACCESSORS,
BACKUP_FILTER_FIELDS,
} from "@/lib/data-filters/backup-filter-fields"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
import { Button } from "@/components/ui/button"
import { PlusIcon } from "lucide-react"
type KindFilter = "all" | "auto" | "manual"
export function BackupsHistory({
backups,
kindFilter,
onKindFilterChange,
search,
onSearchChange,
filters,
onFiltersChange,
onDownload,
onRestore,
onDelete,
onCreate,
}: {
backups: Backup[]
kindFilter: KindFilter
onKindFilterChange: (value: KindFilter) => void
search: string
onSearchChange: (value: string) => void
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onDownload: (id: string, filename: string) => void
onRestore: (backup: Backup) => void
onDelete: (backup: Backup) => void
onCreate: () => void
}) {
const autoCount = backups.filter((b) => b.kind === "auto").length
const manualCount = backups.filter((b) => b.kind === "manual").length
const byKind = kindFilter === "all" ? backups : backups.filter((b) => b.kind === kindFilter)
const q = search.trim().toLowerCase()
const searched = q
? byKind.filter((b) =>
[b.filename, b.server, b.notes].some((v) => v.toLowerCase().includes(q)),
)
: byKind
const filtered = applyReuiFilters(searched, filters, BACKUP_FILTER_ACCESSORS)
const isEmptyAll = backups.length === 0
return (
<DataPageCard>
<DataPageToolbar
segmented={{
value: kindFilter,
onChange: onKindFilterChange,
options: [
{ value: "all", label: "Все", count: backups.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
],
}}
filters={filters}
onFiltersChange={onFiltersChange}
filterFields={BACKUP_FILTER_FIELDS}
search={search}
onSearchChange={onSearchChange}
searchPlaceholder="Поиск по файлу, серверу, заметке…"
countLabel={`${filtered.length} бэкапов`}
/>
<BackupsDataGrid
backups={filtered}
onDownload={onDownload}
onRestore={onRestore}
onDelete={onDelete}
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
emptyDescription={
isEmptyAll
? "Создайте первый бэкап вручную или настройте расписание"
: "Измените фильтры или поисковый запрос"
}
emptyAction={
isEmptyAll ? (
<Button type="button" size="sm" onClick={onCreate}>
<PlusIcon className="size-4" />
Новый бэкап
</Button>
) : null
}
/>
</DataPageCard>
)
}
+419
View File
@@ -0,0 +1,419 @@
"use client"
import type { Server } from "@/lib/data"
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { Badge } from "@/components/reui/badge"
import { OpsPanel } from "@/components/ops-panel"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { LoaderCircleIcon } from "lucide-react"
export const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
export type BackupFreq = "daily" | "weekly" | "monthly"
export type StorageProvider = "local" | "s3"
export type BackupScheduleForm = {
enabled: boolean
frequency: BackupFreq
hour: number
minute: number
weekDay: number
monthDay: number
keepCount: number
format: "rsc" | "backup"
}
export type BackupStorageForm = {
provider: StorageProvider
s3Endpoint: string
s3Region: string
s3Bucket: string
s3Prefix: string
s3AccessKeyId: string
s3SecretAccessKey: string
s3ForcePathStyle: boolean
keepLocalCopy: boolean
showPassword: boolean
}
export const defaultSchedule: BackupScheduleForm = {
enabled: true,
frequency: "daily",
hour: 3,
minute: 0,
weekDay: 0,
monthDay: 1,
keepCount: 7,
format: "rsc",
}
export const defaultStorageForm: BackupStorageForm = {
provider: "local",
s3Endpoint: "",
s3Region: "us-east-1",
s3Bucket: "",
s3Prefix: "mikrotik",
s3AccessKeyId: "",
s3SecretAccessKey: "",
s3ForcePathStyle: true,
keepLocalCopy: true,
showPassword: false,
}
function storageStatus(saved: BackupStorageSettingsDto | null, form: BackupStorageForm) {
if (form.provider === "local") {
return { label: "Локально", variant: "secondary" as const }
}
if (saved?.lastTestError) {
return { label: "Ошибка", variant: "destructive-light" as const }
}
if (saved?.lastTestAt && !saved.lastTestError) {
return { label: "Connected", variant: "success-light" as const }
}
if (saved?.secretConfigured && saved.s3Bucket) {
return { label: "Не проверено", variant: "warning-light" as const }
}
return { label: "Не настроено", variant: "secondary" as const }
}
export function BackupsSettings({
schedule,
onScheduleChange,
storage,
onStorageChange,
savedStorage,
servers,
selectedServers,
onToggleServer,
onSelectAll,
onClearServers,
onSave,
onTest,
onSync,
saveBusy,
testBusy,
syncBusy,
}: {
schedule: BackupScheduleForm
onScheduleChange: <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => void
storage: BackupStorageForm
onStorageChange: <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => void
savedStorage: BackupStorageSettingsDto | null
servers: Server[]
selectedServers: Set<string>
onToggleServer: (id: string) => void
onSelectAll: () => void
onClearServers: () => void
onSave: () => void
onTest: () => void
onSync: () => void
saveBusy: boolean
testBusy: boolean
syncBusy: boolean
}) {
const status = storageStatus(savedStorage, storage)
const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••"
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<OpsPanel title="Расписание" description="Автоматический съём конфигурации" contentClassName="px-5 py-5 flex flex-col gap-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Автоматический бэкап</p>
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
</div>
<FormToggle checked={schedule.enabled} onChange={(v) => onScheduleChange("enabled", v)} />
</div>
<div className={cn("flex flex-col gap-4", !schedule.enabled && "opacity-40 pointer-events-none")}>
<FormField label="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => onScheduleChange("frequency", v)}
options={[
{ value: "daily", label: "Ежедневно" },
{ value: "weekly", label: "Еженедельно" },
{ value: "monthly", label: "Ежемесячно" },
]}
/>
</FormField>
{schedule.frequency === "weekly" && (
<FormField label="День недели">
<div className="flex gap-1">
{WEEK_DAYS.map((d, i) => (
<button
key={d}
type="button"
onClick={() => onScheduleChange("weekDay", i)}
className={cn(
"w-9 h-9 rounded text-sm font-medium border transition-colors",
schedule.weekDay === i
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{d}
</button>
))}
</div>
</FormField>
)}
{schedule.frequency === "monthly" && (
<FormField label="День месяца" hint="128">
<Input
type="number"
min={1}
max={28}
className="font-mono w-24"
value={schedule.monthDay}
onChange={(e) => onScheduleChange("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))}
/>
</FormField>
)}
<FormField label="Время запуска">
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={23}
className="font-mono w-20 text-center"
value={String(schedule.hour).padStart(2, "0")}
onChange={(e) => onScheduleChange("hour", Math.min(23, Math.max(0, Number(e.target.value))))}
/>
<span className="text-muted-foreground font-mono text-lg">:</span>
<div className="flex gap-1">
{[0, 15, 30, 45].map((m) => (
<button
key={m}
type="button"
onClick={() => onScheduleChange("minute", m)}
className={cn(
"px-2.5 py-1.5 rounded text-xs font-mono border transition-colors",
schedule.minute === m
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{String(m).padStart(2, "0")}
</button>
))}
</div>
</div>
</FormField>
<FormField label="Хранить бэкапов" hint="На каждый сервер">
<Input
type="number"
min={1}
max={90}
className="font-mono w-24"
value={schedule.keepCount}
onChange={(e) => onScheduleChange("keepCount", Math.max(1, Number(e.target.value)))}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-3">
<div>
<p className="text-sm font-medium">Формат файла</p>
<p className="text-xs text-muted-foreground mt-0.5">Снимается текстовый экспорт RouterOS</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" size="sm">.rsc</Badge>
<Badge variant="warning-light" size="sm">.backup скоро</Badge>
</div>
</div>
</div>
</OpsPanel>
<OpsPanel
title="Хранилище"
description="Локальный диск приложения или S3-compatible бакет"
headerRight={
<Badge variant={status.variant} size="sm" radius="full">
{status.label}
</Badge>
}
contentClassName="px-5 py-5 flex flex-col gap-5"
>
<FormField label="Тип хранилища">
<SegmentedControl
value={storage.provider}
onChange={(v) => onStorageChange("provider", v)}
options={[
{ value: "local", label: "Локально" },
{ value: "s3", label: "S3" },
]}
/>
</FormField>
{storage.provider === "local" ? (
<p className="text-xs text-muted-foreground">
Файлы пишутся в каталог приложения <span className="font-mono text-foreground">storage/backups</span>.
</p>
) : (
<div className="flex flex-col gap-4">
<FormField label="Endpoint" hint="Пусто для AWS. Для R2/MinIO/Selectel — полный URL">
<Input
className="font-mono"
placeholder="https://s3.amazonaws.com"
value={storage.s3Endpoint}
onChange={(e) => onStorageChange("s3Endpoint", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Region">
<Input
className="font-mono"
placeholder="us-east-1"
value={storage.s3Region}
onChange={(e) => onStorageChange("s3Region", e.target.value)}
/>
</FormField>
<FormField label="Bucket" required>
<Input
className="font-mono"
placeholder="mikrotik-backups"
value={storage.s3Bucket}
onChange={(e) => onStorageChange("s3Bucket", e.target.value)}
/>
</FormField>
</div>
<FormField label="Prefix">
<Input
className="font-mono"
placeholder="mikrotik"
value={storage.s3Prefix}
onChange={(e) => onStorageChange("s3Prefix", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Access key" required>
<Input
className="font-mono"
autoComplete="off"
value={storage.s3AccessKeyId}
onChange={(e) => onStorageChange("s3AccessKeyId", e.target.value)}
/>
</FormField>
<FormField label="Secret key">
<div className="relative">
<Input
type={storage.showPassword ? "text" : "password"}
className="font-mono pr-14"
autoComplete="new-password"
placeholder={secretPlaceholder}
value={storage.s3SecretAccessKey}
onChange={(e) => onStorageChange("s3SecretAccessKey", e.target.value)}
/>
<button
type="button"
onClick={() => onStorageChange("showPassword", !storage.showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-muted-foreground hover:text-foreground"
>
{storage.showPassword ? "скрыть" : "показ"}
</button>
</div>
</FormField>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Path-style</p>
<p className="text-xs text-muted-foreground mt-0.5">Нужен для MinIO и части совместимых API</p>
</div>
<FormToggle checked={storage.s3ForcePathStyle} onChange={(v) => onStorageChange("s3ForcePathStyle", v)} />
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Оставлять локальную копию</p>
<p className="text-xs text-muted-foreground mt-0.5">После успешной загрузки в S3</p>
</div>
<FormToggle checked={storage.keepLocalCopy} onChange={(v) => onStorageChange("keepLocalCopy", v)} />
</div>
{savedStorage?.lastTestError ? (
<p className="text-xs text-destructive">{savedStorage.lastTestError}</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onTest} disabled={testBusy}>
{testBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
Проверить
</Button>
<Button type="button" variant="outline" size="sm" onClick={onSync} disabled={syncBusy}>
{syncBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
Синхронизировать из бакета
</Button>
</div>
</div>
)}
</OpsPanel>
<OpsPanel
className="lg:col-span-2"
title="Серверы для бэкапа"
headerRight={
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
Выбрать все
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={onClearServers}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Сбросить
</button>
</div>
}
contentClassName="px-5 py-5 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{servers.map((s) => {
const checked = selectedServers.has(s.id)
return (
<label
key={s.id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked
? "border-primary/40 bg-primary/5"
: "border-border hover:border-border/80 hover:bg-muted/40",
)}
>
<Checkbox
checked={checked}
onCheckedChange={() => onToggleServer(s.id)}
aria-label={`Выбрать ${s.name}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground">{s.site}</span>
<StatusBadge status={s.status} />
</div>
</div>
</label>
)
})}
</div>
<p className="text-xs text-muted-foreground">
Выбрано {selectedServers.size} из {servers.length} серверов
</p>
</OpsPanel>
<div className="lg:col-span-2 flex items-center gap-3">
<Button type="button" onClick={onSave} className="gap-2" disabled={saveBusy}>
{saveBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Сохранить настройки
</Button>
</div>
</div>
)
}
@@ -0,0 +1,184 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { OpsPanel } from "@/components/ops-panel"
import { FormField, FormToggle } from "@/components/form-kit"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import {
getCertificateRenewSettings,
putCertificateRenewSettings,
} from "@/shared/api/certificates"
import { toast } from "sonner"
/**
* Автообновление сертификатов через MM (ACME DNS-01).
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge
*/
export function CertificateRenewSettingsPanel({
backendUrl,
liveReady,
}: {
backendUrl: string
liveReady: boolean
}) {
const [enabled, setEnabled] = useState(true)
const [intervalDraft, setIntervalDraft] = useState("21600")
const [daysDraft, setDaysDraft] = useState("30")
const [lastCollectedAt, setLastCollectedAt] = useState<string | null>(null)
const [lastError, setLastError] = useState<string | null>(null)
const [loaded, setLoaded] = useState(false)
const [toggleBusy, setToggleBusy] = useState(false)
const [saveBusy, setSaveBusy] = useState(false)
const load = useCallback(async () => {
if (!liveReady) return
try {
const s = await getCertificateRenewSettings(backendUrl)
setEnabled(s.enabled)
setIntervalDraft(String(s.intervalSec))
setDaysDraft(String(s.renewBeforeDays))
setLastCollectedAt(s.lastCollectedAt ?? null)
setLastError(s.lastError ?? null)
setLoaded(true)
} catch (e) {
setLoaded(true)
toast.error(e instanceof Error ? e.message : "Не удалось загрузить настройки автообновления")
}
}, [backendUrl, liveReady])
useEffect(() => {
queueMicrotask(() => {
void load()
})
}, [load])
async function handleEnabledChange(next: boolean) {
if (!liveReady || toggleBusy) return
const prev = enabled
setEnabled(next)
setToggleBusy(true)
try {
const saved = await putCertificateRenewSettings(backendUrl, { enabled: next })
setEnabled(saved.enabled)
toast.success(next ? "Автообновление через MikrotikManager включено" : "Автообновление через MikrotikManager выключено")
} catch (e) {
setEnabled(prev)
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setToggleBusy(false)
}
}
async function handleSaveSchedule() {
if (!liveReady || saveBusy) return
const intervalSec = Math.max(300, Number.parseInt(intervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(daysDraft, 10) || 30))
setSaveBusy(true)
try {
const saved = await putCertificateRenewSettings(backendUrl, { intervalSec, renewBeforeDays })
setIntervalDraft(String(saved.intervalSec))
setDaysDraft(String(saved.renewBeforeDays))
toast.success("Расписание автообновления сохранено")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить расписание")
} finally {
setSaveBusy(false)
}
}
const interactionsOff = !liveReady || toggleBusy || (liveReady && !loaded)
return (
<OpsPanel
title="Автообновление через MikrotikManager"
description="Фоновый выпуск Let's Encrypt (Cloudflare DNS-01) для сертификатов, выпущенных из этой панели. Ручной выпуск не зависит от переключателя."
headerRight={
<Badge
size="sm"
variant={!liveReady ? "warning-light" : enabled ? "success-light" : "secondary"}
>
{!liveReady ? "нет backend" : enabled ? "Включено" : "Выключено"}
</Badge>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Обновлять сертификаты из MM</p>
<p className="text-muted-foreground mt-0.5 text-xs">
Если ACME уже крутит RouterOS выключите, чтобы не было двойного перевыпуска.
</p>
</div>
<FormToggle checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
</div>
{!liveReady ? (
<Alert variant="warning">
<AlertTitle>Нет подключения к API</AlertTitle>
<AlertDescription>
Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных».
</AlertDescription>
</Alert>
) : enabled ? (
<Alert variant="warning">
<AlertTitle>Не смешивайте с ACME RouterOS</AlertTitle>
<AlertDescription>
MM обновляет только сертификаты, выпущенные через эту страницу. Встроенный Let&apos;s Encrypt на
устройстве для тех же имён лучше не включать одновременно.
</AlertDescription>
</Alert>
) : (
<Alert variant="info">
<AlertTitle>Обновление отдано RouterOS</AlertTitle>
<AlertDescription>
Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт
остаются доступны.
</AlertDescription>
</Alert>
)}
<div className={cn("flex flex-col gap-4", !enabled && "pointer-events-none opacity-40")}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<FormField label="Интервал проверки" hint="Секунды, минимум 300">
<Input
inputMode="numeric"
value={intervalDraft}
onChange={(e) => setIntervalDraft(e.target.value)}
disabled={!liveReady || saveBusy}
/>
</FormField>
<FormField label="Обновлять за" hint="Дней до истечения, 1–90">
<Input
inputMode="numeric"
value={daysDraft}
onChange={(e) => setDaysDraft(e.target.value)}
disabled={!liveReady || saveBusy}
/>
</FormField>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" disabled={!liveReady || saveBusy || !enabled} onClick={() => void handleSaveSchedule()}>
Сохранить расписание
</Button>
<Link href="/data-collection" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 text-xs")}>
Журнал планировщика
</Link>
</div>
</div>
{lastCollectedAt || lastError ? (
<p className="text-muted-foreground text-xs">
Последний прогон:{" "}
{lastCollectedAt ? new Date(lastCollectedAt).toLocaleString("ru-RU") : "ещё не было"}
{lastError ? ` · ошибка: ${lastError}` : ""}
</p>
) : null}
</OpsPanel>
)
}
@@ -0,0 +1,90 @@
"use client"
import Link from "next/link"
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/reui/timeline"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { cn } from "@/lib/utils"
import type { EventItem } from "@mmapp/contracts/events"
function formatEventAge(iso: string): string {
const ts = Date.parse(iso)
if (!Number.isFinite(ts)) return "—"
const diffMs = Math.max(0, Date.now() - ts)
const minutes = Math.floor(diffMs / 60_000)
if (minutes < 1) return "сейчас"
if (minutes < 60) return `${minutes}м`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}ч`
const days = Math.floor(hours / 24)
return `${days}д`
}
const LEVEL_DOT: Record<EventItem["level"], string> = {
critical: "border-destructive bg-destructive/20 group-data-completed/timeline-item:border-destructive",
warning: "border-warning bg-warning/20 group-data-completed/timeline-item:border-warning",
info: "border-info bg-info/20 group-data-completed/timeline-item:border-info",
}
/**
* Compact activity timeline.
* Preview: https://reui.io/preview/base/timeline-3
* Docs: https://reui.io/docs/components/base/timeline
*/
export function DashboardEventsTimeline({
events,
loading,
error,
}: {
events: EventItem[]
loading?: boolean
error?: string | null
}) {
if (loading && events.length === 0) {
return <p className="text-muted-foreground px-5 py-6 text-sm">Загрузка событий</p>
}
if (error && events.length === 0) {
return (
<div className="px-5 py-4">
<Alert variant="destructive">
<AlertTitle>События недоступны</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)
}
if (events.length === 0) {
return (
<p className="text-muted-foreground px-5 py-6 text-sm">
Событий пока нет.{" "}
<Link href="/alerts" className="underline underline-offset-2">
Оповещения
</Link>
</p>
)
}
return (
<Timeline defaultValue={events.length} className="px-5 py-4">
{events.map((event, index) => (
<TimelineItem key={event.id} step={index + 1}>
<TimelineHeader>
<TimelineSeparator />
<TimelineDate>{formatEventAge(event.createdAt)}</TimelineDate>
<TimelineTitle className="text-[13px] leading-tight">{event.title}</TimelineTitle>
<TimelineIndicator className={cn(LEVEL_DOT[event.level])} />
</TimelineHeader>
<TimelineContent className="text-xs leading-snug">{event.message}</TimelineContent>
</TimelineItem>
))}
</Timeline>
)
}
+3 -20
View File
@@ -2,8 +2,6 @@
import { useMemo, useState } from "react"
import { Flag } from "@/components/flag"
import { OpsPanel } from "@/components/ops-panel"
import { StatusBadge } from "@/components/status-badge"
import { cn } from "@/lib/utils"
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
import { Maximize2Icon, ZoomInIcon, ZoomOutIcon } from "lucide-react"
@@ -167,7 +165,7 @@ function ServerNode({
)
}
export function InternetPathMapCard({ model }: { model: InternetPathViewModel | null }) {
export function InternetPathMapCanvas({ model }: { model: InternetPathViewModel | null }) {
const [zoom, setZoom] = useState(1)
const [pan, setPan] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
@@ -226,22 +224,7 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
}
return (
<OpsPanel
title="Internet path map"
description="Основной и текущий путь трафика HomeRouter → Internet"
headerRight={
<StatusBadge
status={
model?.pathState === "healthy"
? "online"
: model?.pathState === "failover"
? "degraded"
: "offline"
}
/>
}
contentClassName="px-5 pb-4"
>
<div className="flex flex-col gap-3">
{!model && (
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
Недостаточно данных для построения маршрута
@@ -412,6 +395,6 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
)}
</div>
)}
</OpsPanel>
</div>
)
}
@@ -0,0 +1,97 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronDownIcon } from "lucide-react"
import { OpsPanel } from "@/components/ops-panel"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { buttonVariants } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
import { InternetPathMapCanvas } from "@/components/dashboard/internet-path-map"
import { InternetPathSummary } from "@/components/dashboard/internet-path-summary"
const PATH_MAP_OPEN_LS = "mm:dashboard-path-map-open"
function readMapOpen(): boolean {
if (typeof window === "undefined") return true
try {
const raw = localStorage.getItem(PATH_MAP_OPEN_LS)
if (raw === "0") return false
if (raw === "1") return true
} catch {
/* ignore */
}
return true
}
export function InternetPathPanel({
model,
loading,
error,
}: {
model: InternetPathViewModel | null
loading?: boolean
error?: string | null
}) {
const [open, setOpen] = useState(true)
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
queueMicrotask(() => {
setOpen(readMapOpen())
setHydrated(true)
})
}, [])
function handleOpenChange(next: boolean) {
setOpen(next)
try {
localStorage.setItem(PATH_MAP_OPEN_LS, next ? "1" : "0")
} catch {
/* ignore */
}
}
return (
<OpsPanel
title="Internet path"
description="Home → WAN → JH → Exit"
headerRight={
<Link href="/network-map" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
Карта сети
</Link>
}
contentClassName="flex flex-col gap-3 px-5 pb-4"
>
{error ? (
<Alert variant="destructive">
<AlertTitle>Не удалось загрузить путь</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading && !model ? (
<div className="h-20 animate-pulse rounded-md bg-muted/40" />
) : (
<InternetPathSummary model={model} />
)}
<Collapsible open={hydrated ? open : true} onOpenChange={handleOpenChange}>
<CollapsibleTrigger
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 w-fit gap-1.5 text-xs")}
>
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
{open ? "Скрыть карту" : "Показать карту"}
</CollapsibleTrigger>
<CollapsibleContent>
<InternetPathMapCanvas model={model} />
</CollapsibleContent>
</Collapsible>
</OpsPanel>
)
}
@@ -0,0 +1,130 @@
"use client"
import type { ReactNode } from "react"
import Link from "next/link"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { StatusBadge } from "@/components/status-badge"
import { Flag } from "@/components/flag"
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
import type { ServerStatus } from "@/lib/data"
import { cn } from "@/lib/utils"
import { ArrowRightIcon, HomeIcon, RadioIcon, ServerIcon, GlobeIcon } from "lucide-react"
function pathStateToServerStatus(state: InternetPathViewModel["pathState"]): ServerStatus {
if (state === "healthy") return "online"
if (state === "failover" || state === "degraded") return "degraded"
return "offline"
}
function HopChip({
label,
name,
country,
icon,
iconClassName,
}: {
label: string
name: string
country?: string
icon: ReactNode
iconClassName?: string
}) {
return (
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 px-2 py-1.5">
<IconTile variant="elevated" size="sm" className={cn("shrink-0", iconClassName)} aria-hidden="true">
{icon}
</IconTile>
<div className="min-w-0">
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">{label}</p>
<p className="flex items-center gap-1 truncate text-sm font-medium">
{country ? <Flag code={country} className="shrink-0" /> : null}
<span className="truncate">{name}</span>
</p>
</div>
</div>
)
}
/**
* Compact live path strip (Frame-friendly). Canvas lives separately.
* Preview: https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/icon-tile
*/
export function InternetPathSummary({ model }: { model: InternetPathViewModel | null }) {
if (!model) {
return (
<p className="text-muted-foreground text-sm">
Недостаточно данных для пути. Добавьте home-router и проверьте{" "}
<Link href="/network-map" className="underline underline-offset-2">
карту сети
</Link>
.
</p>
)
}
const hop = model.currentHop ?? model.primaryHop
const wanName = hop?.wan.name ?? model.activeWanUplink?.name ?? "WAN"
const wanIsp = hop?.wan.isp ?? model.activeWanUplink?.isp ?? "—"
const ping = hop?.wanJhMetrics.pingMs
const dl = hop?.wanJhMetrics.dlMbps
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={pathStateToServerStatus(model.pathState)} />
{model.pathState === "failover" ? (
<Badge variant="warning-light" size="sm">failover</Badge>
) : null}
{ping != null ? (
<Badge variant="outline" size="sm" className="tabular-nums">
{ping} мс
</Badge>
) : null}
{dl != null ? (
<Badge variant="outline" size="sm" className="tabular-nums">
{Math.round(dl)} Мбит/с
</Badge>
) : null}
</div>
<div className="flex flex-col gap-2 @3xl:flex-row @3xl:items-center">
<HopChip
label="Home"
name={model.homeRouter.name}
country={model.homeRouter.country}
icon={<HomeIcon />}
iconClassName="text-success"
/>
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
<HopChip
label="WAN"
name={`${wanName} · ${wanIsp}`}
icon={<RadioIcon />}
iconClassName="text-info"
/>
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
<HopChip
label="JH"
name={hop?.jumpHost.name ?? model.fallbackJumpHost?.name ?? "—"}
country={hop?.jumpHost.country ?? model.fallbackJumpHost?.country}
icon={<ServerIcon />}
iconClassName="text-primary"
/>
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
<HopChip
label="Exit"
name={hop?.exitNode.name ?? model.fallbackExitNode?.name ?? "—"}
country={hop?.exitNode.country ?? model.fallbackExitNode?.country}
icon={<GlobeIcon />}
iconClassName="text-muted-foreground"
/>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
{model.currentPath?.reason ?? model.primaryPath?.reason ?? "Текущий путь не определён"}
</p>
</div>
)
}
+79 -23
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo } from "react"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
@@ -8,8 +8,9 @@ import {
useReactTable,
} from "@tanstack/react-table"
import type { Backup } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
@@ -18,13 +19,28 @@ import {
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
import {
CloudIcon,
DownloadIcon,
HardDriveIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-react"
interface BackupsDataGridProps {
backups: Backup[]
onDownload: (id: string, filename: string) => void
onRestore: (backup: Backup) => void
onDelete: (id: string) => void
onDelete: (backup: Backup) => void
emptyAction?: ReactNode
emptyTitle?: string
emptyDescription?: string
}
function storageBadge(storage: Backup["storage"]) {
if (storage === "s3") return { label: "S3", variant: "info-light" as const }
if (storage === "both") return { label: "Локально + S3", variant: "success-light" as const }
return { label: "Локально", variant: "secondary" as const }
}
function BackupsDataGrid({
@@ -32,6 +48,9 @@ function BackupsDataGrid({
onDownload,
onRestore,
onDelete,
emptyAction,
emptyTitle = "Нет бэкапов",
emptyDescription = "Создайте первый бэкап вручную или настройте расписание",
}: BackupsDataGridProps) {
const columns = useMemo<ColumnDef<Backup>[]>(
() => [
@@ -39,9 +58,27 @@ function BackupsDataGrid({
id: "filename",
accessorKey: "filename",
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
),
cell: ({ row }) => {
const b = row.original
const inS3 = b.storage === "s3" || b.storage === "both"
return (
<div className="flex items-center gap-3 min-w-0">
<IconTile
variant="elevated"
className={inS3 ? "size-10.5 shrink-0 text-info" : "size-10.5 shrink-0 text-muted-foreground"}
aria-hidden="true"
>
{inS3 ? <CloudIcon /> : <HardDriveIcon />}
</IconTile>
<div className="min-w-0">
<span className="font-mono text-xs font-medium block truncate">{b.filename}</span>
{b.uploadError ? (
<span className="text-[11px] text-destructive truncate block">{b.uploadError}</span>
) : null}
</div>
</div>
)
},
meta: {
headerTitle: "Файл",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
@@ -78,23 +115,35 @@ function BackupsDataGrid({
id: "kind",
accessorKey: "kind",
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => (
<Badge
variant={row.original.kind === "manual" ? "info-light" : "secondary"}
size="sm"
radius="full"
>
{row.original.kind === "auto" ? "авто" : "вручную"}
</Badge>
),
meta: {
headerTitle: "Тип",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "storage",
accessorKey: "storage",
header: ({ column }) => <DataGridSortHeader column={column} title="Хранилище" />,
cell: ({ row }) => {
const kind = row.original.kind
const badge = storageBadge(row.original.storage)
return (
<span
className={cn(
"text-xs px-2 py-0.5 rounded border font-medium",
kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border",
)}
>
{kind === "auto" ? "авто" : "вручную"}
</span>
<Badge variant={badge.variant} size="sm" radius="full">
{badge.label}
</Badge>
)
},
meta: {
headerTitle: "Тип",
headerTitle: "Хранилище",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
@@ -135,29 +184,35 @@ function BackupsDataGrid({
return (
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
title="Скачать"
aria-label={`Скачать ${b.filename}`}
onClick={() => onDownload(b.id, b.filename)}
>
<DownloadIcon className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
title="Восстановить"
aria-label={`Восстановить ${b.filename}`}
onClick={() => onRestore(b)}
>
<RefreshCwIcon className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
title="Удалить"
onClick={() => onDelete(b.id)}
aria-label={`Удалить ${b.filename}`}
onClick={() => onDelete(b)}
>
<Trash2Icon className="size-3.5" />
</Button>
@@ -186,9 +241,10 @@ function BackupsDataGrid({
if (backups.length === 0) {
return (
<EmptyState
icon={<HardDriveIcon className="size-4" />}
title="Нет бэкапов"
description="Создайте первый бэкап вручную или настройте расписание"
icon={<HardDriveIcon className="size-5" />}
title={emptyTitle}
description={emptyDescription}
action={emptyAction}
className="border-0 py-10"
/>
)
+8 -1
View File
@@ -1,13 +1,14 @@
"use client"
import { ReactNode } from "react"
import { SearchIcon } from "lucide-react"
import { ListFilterIcon, SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { Button } from "@/components/ui/button"
import {
Filters,
type Filter,
@@ -77,6 +78,12 @@ function DataPageToolbar<T extends string = string>({
fields={filterFields}
onChange={onFiltersChange}
size="sm"
trigger={
<Button type="button" variant="outline" size="sm">
<ListFilterIcon className="size-3.5" />
Фильтры
</Button>
}
/>
)}
{onSearchChange != null && (
@@ -125,6 +125,117 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n
<path d="M13.4 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H13.4Z" fill="#FE2C55" transform="translate(1.2 1)" />
</BrandSvg>
)
case "apple":
return (
<BrandSvg size={size}>
<path d="M14.7 6.2c.8-.9 1.3-2.2 1.2-3.5-1.2.1-2.6.8-3.4 1.8-.8.9-1.5 2.2-1.3 3.5 1.3 0 2.6-.8 3.5-1.8Z" fill="#111" />
<path d="M16.8 12.2c0-2.2 1.8-3.3 1.9-3.4-1.1-1.6-2.7-1.8-3.3-1.8-1.4-.1-2.7.8-3.4.8s-1.8-.8-3-.8c-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.3 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7 2-.1 2.9-2.2c1.1-1.5 1.5-3 1.5-3.1-.1 0-2.9-1.1-2.9-4.4Z" fill="#111" />
</BrandSvg>
)
case "github":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#181717" />
<path d="M12 6.4c-3.1 0-5.6 2.5-5.6 5.6 0 2.5 1.6 4.6 3.8 5.3.3.1.4-.1.4-.3v-1.1c-1.6.3-1.9-.7-1.9-.7-.3-.6-.6-.8-.6-.8-.5-.4 0-.4 0-.4.6 0 .9.6.9.6.5.9 1.4.6 1.7.5.1-.4.2-.6.4-.8-1.2-.1-2.5-.6-2.5-2.8 0-.6.2-1.1.6-1.5-.1-.1-.3-.7 0-1.4 0 0 .5-.2 1.6.6.5-.1 1-.2 1.5-.2s1 .1 1.5.2c1.1-.8 1.6-.6 1.6-.6.3.7.1 1.3 0 1.4.4.4.6.9.6 1.5 0 2.2-1.3 2.6-2.5 2.8.2.2.4.5.4 1.1v1.6c0 .2.1.4.4.3 2.2-.7 3.8-2.8 3.8-5.3 0-3.1-2.5-5.6-5.6-5.6Z" fill="#fff" />
</BrandSvg>
)
case "gitlab":
return (
<BrandSvg size={size}>
<path d="M12 19.2 8.4 8.4h7.2L12 19.2Z" fill="#E24329" />
<path d="M12 19.2 8.4 8.4 5.2 16.2 12 19.2Z" fill="#FC6D26" />
<path d="M12 19.2 15.6 8.4 18.8 16.2 12 19.2Z" fill="#FC6D26" />
<path d="M5.2 16.2 3 8.4h5.4L5.2 16.2Z" fill="#FCA326" />
<path d="M18.8 16.2 21 8.4h-5.4l3.2 7.8Z" fill="#FCA326" />
</BrandSvg>
)
case "spotify":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#1DB954" />
<path d="M7.2 10.4c3.2-1 6.8-.8 9.6.8M7.6 13c2.6-.8 5.6-.6 8 .6M8 15.4c2-.6 4.4-.4 6.2.4" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
</BrandSvg>
)
case "x":
return (
<BrandSvg size={size}>
<rect width="24" height="24" rx="5" fill="#111" />
<path d="M6.2 5.6h3.2l3 4.2 3.6-4.2H18l-5.2 6.1 5.4 6.7h-3.2l-3.4-4.4-4 4.4H6.4l5.6-6.4Z" fill="#fff" />
</BrandSvg>
)
case "vk":
return (
<BrandSvg size={size}>
<rect width="24" height="24" rx="5" fill="#0077FF" />
<path d="M4.8 7.8h2.6c.1 4.2 1.9 6.7 5.4 6.7V7.8h2.4v3.9c1.5-.2 2.9-1.7 3.4-3.9h2.4c-.6 3.3-2.6 5.4-4.4 6.2 1.8.6 4.1 2.4 5 5.2h-2.8c-.7-1.9-2.2-3.4-4-3.6v3.6h-2.4v-3.6c-3.5.1-6.1-2.4-6.6-6.8Z" fill="#fff" />
</BrandSvg>
)
case "zoom":
return (
<BrandSvg size={size}>
<rect width="24" height="24" rx="5" fill="#2D8CFF" />
<path d="M5.2 9.2h7.2a2.2 2.2 0 0 1 2.2 2.2v5.2H7.4A2.2 2.2 0 0 1 5.2 14.4Z" fill="#fff" />
<path d="M16.2 11.2 20 9.4v7.4l-3.8-1.8Z" fill="#fff" />
</BrandSvg>
)
case "epic":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#111" />
<path d="M8.2 7.4h7.6v2H10.6v2h4.6v2h-4.6v3.2H8.2Z" fill="#fff" />
</BrandSvg>
)
case "riot":
return (
<BrandSvg size={size}>
<path d="M5 19.2 12 4.2 19 19.2h-3.2L12 10.6 8.2 19.2Z" fill="#D32936" />
<path d="M9.4 19.2h5.2l-2.6-5.2Z" fill="#EB0029" />
</BrandSvg>
)
case "playstation":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#003087" />
<path d="M8.2 14.6c-1 .4-1.8.2-2-.4s.4-1.2 1.6-1.6l2-.7v1.6l-1.2.4c-.6.2-.8.4-.7.6.1.2.4.2.9 0l1-.4v1.5Zm3-6.4v8.2c-1.1.4-2.1.5-2.8.2-.9-.4-.9-1.3 0-1.7.5-.2 1.2-.3 2-.2V9.4c0-1.2.5-1.8 1.4-1.5.4.2.7.6.8 1.2Zm5.2 7.6c-1.1.4-2.2.4-3 0-.8-.4-.8-1.2 0-1.6.5-.2 1.2-.3 2-.2v-2.2l-2.4.8V11l4.2-1.5v6.3Z" fill="#fff" />
</BrandSvg>
)
case "roblox":
return (
<BrandSvg size={size}>
<rect width="24" height="24" rx="5" fill="#111" />
<path d="M8.4 6.2 17.6 8.8 15.6 17.8 6.4 15.2Z" fill="#fff" />
</BrandSvg>
)
case "digitalocean":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#0080FF" />
<path d="M12.4 6.2A5.8 5.8 0 0 0 7.8 16l1.6-1.5A3.6 3.6 0 1 1 16 12h-3.6Z" fill="#fff" />
<path d="M12.4 16.2h-1.6v1.6h1.6zm-1.6-2h-1.4v1.4h1.4z" fill="#fff" />
</BrandSvg>
)
case "hetzner":
return (
<BrandSvg size={size}>
<rect width="24" height="24" rx="4" fill="#D50C2D" />
<path d="M7.2 6.4h2.6v4.4h4.4V6.4h2.6v11.2h-2.6v-4.4H9.8v4.4H7.2Z" fill="#fff" />
</BrandSvg>
)
case "ovh":
return (
<BrandSvg size={size}>
<rect width="24" height="24" rx="4" fill="#123F6D" />
<path d="M4.6 15.6 8.4 8.4h3.2L7.8 15.6Zm6.4 0 3.8-7.2h3.2l-3.8 7.2Zm2.2 0h3.4l1.8-3.4h-3.4Z" fill="#00A2E2" />
</BrandSvg>
)
case "chatgpt":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#10A37F" />
<path d="M12.2 6.2c.9-.5 2-.5 2.9 0l2.2 1.3c.9.5 1.4 1.4 1.4 2.4v2.6c0 1-.5 1.9-1.4 2.4l-2.2 1.3c-.9.5-2 .5-2.9 0l-.4-.2c.6-.4 1-1 1.1-1.7l.5.3c.4.2.9.2 1.3 0l2.2-1.3c.4-.2.6-.6.6-1.1V10c0-.4-.2-.8-.6-1.1l-2.2-1.3c-.4-.2-.9-.2-1.3 0L10.2 9c-.4.2-.6.6-.6 1.1v.4h-2V10c0-1 .5-1.9 1.4-2.4Z" fill="#fff" />
<path d="M8.8 9.6c.6-.4 1.3-.5 2-.3v2.1c0 .4.2.8.6 1.1l2.2 1.3c.4.2.9.2 1.3 0l.5-.3c.2.7.6 1.3 1.1 1.7l-.4.2c-.9.5-2 .5-2.9 0l-2.2-1.3c-.9-.5-1.4-1.4-1.4-2.4Z" fill="#fff" opacity="0.85" />
</BrandSvg>
)
default:
return <GenericCloud size={size} />
}
+93
View File
@@ -0,0 +1,93 @@
"use client"
import type { ReactNode } from "react"
import type { LucideIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { cn } from "@/lib/utils"
/**
* Sibling Frame columns for dashboard attention queue.
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
*/
export interface AttentionQueueColumn {
id: string
title: string
icon: LucideIcon
iconClassName?: string
count: number
countVariant?: "destructive" | "warning" | "secondary" | "destructive-light" | "warning-light"
emptyTitle: string
emptyDescription: string
emptyAction?: ReactNode
children: ReactNode
}
interface AttentionQueueProps {
columns: AttentionQueueColumn[]
className?: string
}
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
export function AttentionQueue({ columns, className }: AttentionQueueProps) {
return (
<div
className={cn(
"@container grid min-w-0 items-start gap-2 @3xl:grid-cols-3",
className,
)}
>
{columns.map((column) => {
const Icon = column.icon
const isEmpty = column.count === 0
return (
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
aria-hidden="true"
>
<Icon />
</IconTile>
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
{column.count > 0 ? (
<Badge
size="sm"
variant={column.countVariant ?? "secondary"}
className="tabular-nums"
>
{column.count}
</Badge>
) : null}
</div>
</FrameHeader>
<FramePanel className="min-w-0">
{isEmpty ? (
<div className="flex min-h-24 flex-col items-start justify-center gap-1 py-3">
<p className="text-sm font-medium">{column.emptyTitle}</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{column.emptyDescription}
</p>
{column.emptyAction ? <div className="pt-1">{column.emptyAction}</div> : null}
</div>
) : (
column.children
)}
</FramePanel>
</Frame>
)
})}
</div>
)
}
+4
View File
@@ -3,3 +3,7 @@ export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
export { kpiCols } from "./kpi-cols"
export { QuickActionGrid } from "./quick-action-grid"
export type { QuickActionItem } from "./quick-action-grid"
export { AttentionQueue } from "./attention-queue"
export type { AttentionQueueColumn } from "./attention-queue"
+161
View File
@@ -0,0 +1,161 @@
"use client"
import type { KeyboardEvent, ReactNode } from "react"
import Link from "next/link"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { cn } from "@/lib/utils"
import { kpiCols } from "./kpi-cols"
type QuickActionBase = {
id: string
title: string
description: string
icon?: ReactNode
iconClassName?: string
badge?: string
disabled?: boolean
}
export type QuickActionItem = QuickActionBase &
(
| { href: string; onClick?: never }
| { onClick: () => void; href?: never }
)
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
}
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
function resolveBadge(action: QuickActionItem): string {
if (action.badge) return action.badge
return action.onClick ? "Выполнить" : "Перейти"
}
function handleActionKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onActivate()
}
}
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn("size-10.5", action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
{resolveBadge(action)}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
</div>
</div>
)
}
function panelClassName(disabled?: boolean) {
return cn(
"relative isolate flex h-full flex-col transition-colors",
disabled
? "cursor-not-allowed opacity-60"
: "hover:bg-muted/40 focus-within:ring-ring cursor-pointer focus-within:ring-2",
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
*/
export function QuickActionGrid({
actions,
title = "Быстрые действия",
description,
className,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn("@container w-full", className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div className={cn("grid gap-2", kpiCols(actions.length))}>
{actions.map((action) => {
const label = `${action.title}: ${action.description}`
if ("href" in action && action.href) {
return (
<FramePanel key={action.id} className={panelClassName(action.disabled)}>
{action.disabled ? (
<div aria-disabled aria-label={label}>
<QuickActionBody action={action} />
</div>
) : (
<>
<QuickActionBody action={action} />
<Link
href={action.href}
className="absolute inset-0 z-20 focus-visible:outline-none"
aria-label={label}
/>
</>
)}
</FramePanel>
)
}
const onClick = action.onClick
const onActivate = () => {
if (action.disabled || !onClick) return
onClick()
}
return (
<FramePanel
key={action.id}
className={panelClassName(action.disabled)}
role="button"
tabIndex={action.disabled ? -1 : 0}
aria-disabled={action.disabled || undefined}
aria-label={label}
onClick={onActivate}
onKeyDown={(e) => handleActionKeyDown(onActivate, e)}
>
<QuickActionBody action={action} />
</FramePanel>
)
})}
</div>
</Frame>
)
}
+55 -46
View File
@@ -70,63 +70,72 @@ export function TrafficRxTxChart({
rx,
tx,
range = "1h",
embedded = false,
}: {
rx: number[]
tx: number[]
range?: string
/** Skip outer Frame when already inside OpsPanel / Frame. */
embedded?: boolean
}) {
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
const data = toChartData(rx, tx, rangeMinutes)
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
const chart = (
<div className="flex flex-col gap-4">
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid
strokeDasharray="4 8"
vertical={false}
stroke="var(--border)"
/>
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickMargin={10}
interval={tickEvery - 1}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickFormatter={(v: number) => fmtRate(Number(v))}
tickMargin={8}
width={72}
/>
<ChartTooltip content={<CustomTooltip />} />
<Line
dataKey="rx"
type="monotone"
stroke="var(--chart-rx)"
strokeWidth={2}
dot={false}
/>
<Line
dataKey="tx"
type="monotone"
stroke="var(--chart-tx)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
<div className="mb-1 flex items-center justify-center gap-6">
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
</div>
</div>
)
if (embedded) return chart
return (
<Frame className="w-full">
<FramePanel className="flex flex-col gap-6">
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid
strokeDasharray="4 8"
vertical={false}
stroke="var(--border)"
/>
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickMargin={10}
interval={tickEvery - 1}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickFormatter={(v: number) => fmtRate(Number(v))}
tickMargin={8}
width={72}
/>
<ChartTooltip content={<CustomTooltip />} />
<Line
dataKey="rx"
type="monotone"
stroke="var(--chart-rx)"
strokeWidth={2}
dot={false}
/>
<Line
dataKey="tx"
type="monotone"
stroke="var(--chart-tx)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
<div className="mb-1 flex items-center justify-center gap-6">
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
</div>
</FramePanel>
<FramePanel className="flex flex-col gap-6">{chart}</FramePanel>
</Frame>
)
}
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
import type { GeoipStatusDto } from "@mmapp/contracts/geoip"
import {
generateTrafficFlowKeys,
getTrafficFlowHostFiles,
@@ -17,6 +18,7 @@ import {
purgeTrafficFlowData,
putTrafficFlowSettings,
} from "@/shared/api/traffic-flow"
import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "@/shared/api/geoip"
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
@@ -29,6 +31,127 @@ const HOST_STEPS = [
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
]
function fmtDate(iso: string | null | undefined): string {
return iso ? new Date(iso).toLocaleString("ru-RU") : "—"
}
function GeoipSettingsSection({ backendUrl }: { backendUrl: string }) {
const [status, setStatus] = useState<GeoipStatusDto | null>(null)
const [busy, setBusy] = useState(false)
const [updating, setUpdating] = useState(false)
const [autoOn, setAutoOn] = useState(true)
const [intervalHours, setIntervalHours] = useState("168")
const load = useCallback(async () => {
const s = await getGeoipStatus(backendUrl)
setStatus(s)
setAutoOn(s.settings.enabled)
setIntervalHours(String(Math.round(s.settings.updateIntervalSec / 3600)))
}, [backendUrl])
useEffect(() => {
void load().catch((e: unknown) => {
toast.error(e instanceof Error ? e.message : "Не удалось загрузить GeoIP")
})
}, [load])
async function handleSave() {
setBusy(true)
try {
const hours = Math.min(720, Math.max(6, Number.parseInt(intervalHours, 10) || 168))
const res = await putGeoipSettings(backendUrl, {
enabled: autoOn,
updateIntervalSec: hours * 3600,
})
setStatus(res.status)
toast.success("Настройки GeoIP сохранены")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setBusy(false)
}
}
async function handleUpdateNow() {
setUpdating(true)
try {
const res = await runGeoipUpdateNow(backendUrl)
if (res.ok || res.snapshot.downloaded > 0 || res.snapshot.skippedUnchanged > 0) {
toast.success(
res.snapshot.downloaded > 0
? `Скачано баз: ${res.snapshot.downloaded} (${(res.snapshot.bytes / 1024 / 1024).toFixed(1)} МБ)`
: "Базы актуальны, скачивание не требуется",
)
} else {
toast.error(res.snapshot.errors.join("; ") || "Обновление не выполнено")
}
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось обновить базы")
} finally {
setUpdating(false)
}
}
return (
<OpsPanel
title="GeoIP-базы (GeoLite2)"
description="Локальные mmdb MaxMind GeoLite2 с зеркала P3TERX: страна и ASN каждого потока при ingest — мгновенно, включая IPv6, без лимитов RIPEstat."
headerRight={
<div className="flex items-center gap-2">
<Badge variant={status?.countryLoaded ? "success" : "secondary"}>
country {status?.countryLoaded ? "ok" : "нет"}
</Badge>
<Badge variant={status?.asnLoaded ? "success" : "secondary"}>
asn {status?.asnLoaded ? "ok" : "нет"}
</Badge>
</div>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-3">
<FormToggle checked={autoOn} onChange={setAutoOn} />
<span className="text-sm">Автообновление</span>
</div>
<FormField label="Интервал проверки (часов)" hint="Upstream обновляется еженедельно; минимум 6 ч">
<Input
className="font-mono"
value={intervalHours}
onChange={(e) => setIntervalHours(e.target.value)}
inputMode="numeric"
disabled={!autoOn}
/>
</FormField>
</div>
<p className="text-xs text-muted-foreground">
Сборка Country: {fmtDate(status?.settings.countryBuildAt)} · ASN: {fmtDate(status?.settings.asnBuildAt)}
{" · "}последняя проверка: {fmtDate(status?.settings.lastCheckAt)}
{status?.settings.lastError ? ` · ошибка: ${status.settings.lastError}` : ""}
</p>
<p className="text-xs text-muted-foreground">
Каталог: <span className="font-mono">{status?.dir || "storage/geoip"}</span>. До загрузки баз
и при промахе lookup страна/ASN берутся из RIPEstat, как раньше.
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy || updating} onClick={() => { void handleSave() }}>
Сохранить GeoIP
</Button>
<Button size="sm" variant="outline" disabled={busy || updating} onClick={() => { void handleUpdateNow() }}>
<DownloadIcon className={updating ? "size-4 animate-spin" : "size-4"} />
{updating ? "Обновление…" : "Обновить сейчас"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb.
</p>
</OpsPanel>
)
}
function NetflowSettingsPanel({
backendUrl,
enabled,
@@ -276,6 +399,8 @@ function NetflowSettingsPanel({
</div>
</OpsPanel>
<GeoipSettingsSection backendUrl={backendUrl} />
<CodeExportSheet
open={exportOpen}
onClose={() => setExportOpen(false)}
+687
View File
@@ -0,0 +1,687 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { usePathname } from "next/navigation"
import {
dashLatency,
greTunnels as mockGreTunnels,
pingProbes,
servers as mockServers,
traffic as mockTraffic,
vxlanTunnels as mockVxlan,
type GreTunnel,
type PingProbe,
type Server,
type ServerStatus,
type ServerType,
} from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { listEvents } from "@/shared/api/events"
import { listWireGuard } from "@/shared/api/wireguard"
import type { EventItem } from "@mmapp/contracts/events"
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
import {
buildDashboardInternetPath,
type HomeWanRuntime,
resolveDefaultRouteLookup,
type InternetPathViewModel,
} from "@/lib/dashboard-internet-path"
import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-optimizer-data"
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed"
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
function readMockDashboardStarIds(): Set<string> {
if (typeof window === "undefined") return new Set()
try {
const raw = localStorage.getItem(MOCK_DASH_STARS_LS)
const arr = raw ? (JSON.parse(raw) as unknown) : []
return new Set(Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [])
} catch {
return new Set()
}
}
interface BackendServerRow {
id: number
name: string
host: string
site: string
country: string
asn: string
type: ServerType
enabled: boolean
status: "online" | "offline" | null
latency: number | null
os: string | null
model: string | null
sessions?: number
wanUplinks?: Array<{
id: string
name: string
isp: string
iface: string
ip: string
maxDl: number
maxUl: number
}>
}
interface ApiGreTunnelRow {
id: string
name: string
serverId: string
localAddress: string
remoteAddress: string
localInnerIp: string
remoteInnerIp: string
poolId: string
ipsec: null
mtu: number
keepaliveInterval: number
keepaliveRetries: number
dscp: "inherit" | number
clampTcpMss: boolean
allowFastPath: boolean
comment: string
enabled: boolean
status: "up" | "down" | "degraded"
}
interface InternetPathSnapshotPayload {
sampledAt: string
servers: BackendServerRow[]
greTunnels: ApiGreTunnelRow[]
filtersRulesets: FiltersRulesetRow[]
speedProbes: RouteOptimizerSpeedProbe[]
routeLookupByServerId: Record<string, { gateway: string | null; routingMark: string | null } | null>
wanRuntimeByHomeId: Record<string, HomeWanRuntime | null>
}
interface TrafficServerRow {
id: string
rxNow: number
txNow: number
rxSeries: number[]
txSeries: number[]
}
export type OverlayKind = "gre" | "wg" | "vxlan"
export interface OverlayItem {
id: string
name: string
kind: OverlayKind
href: string
status: "up" | "down" | "degraded"
}
export interface AttentionRow {
id: string
title: string
hint: string
href: string
tone: "destructive" | "warning"
}
export type LatencyBlock =
| { kind: "loading" }
| { kind: "empty"; message: string }
| { kind: "mock"; series: Record<string, number[]>; labels?: Record<string, string>; subtitle: string }
| { kind: "live"; series: Record<string, number[]>; labels?: Record<string, string>; subtitle: string }
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
return {
id: t.id,
name: t.name,
serverId: String(t.serverId),
localAddress: t.localAddress,
remoteAddress: t.remoteAddress,
localInnerIp: t.localInnerIp,
remoteInnerIp: t.remoteInnerIp,
poolId: t.poolId || "live",
ipsec: null,
mtu: t.mtu,
keepaliveInterval: t.keepaliveInterval,
keepaliveRetries: t.keepaliveRetries,
dscp: t.dscp,
clampTcpMss: t.clampTcpMss,
allowFastPath: t.allowFastPath,
comment: t.comment,
enabled: t.enabled,
status: t.status,
}
}
function mapBackendToServer(s: BackendServerRow): Server {
const wanUplinks = Array.isArray(s.wanUplinks)
? s.wanUplinks
.filter((w) => typeof w === "object" && w != null)
.map((w, idx) => ({
id: String(w.id || `wan-${s.id}-${idx + 1}`),
name: String(w.name || `WAN${idx + 1}`),
isp: String(w.isp || "—"),
iface: String(w.iface || ""),
ip: String(w.ip || ""),
maxDl: Math.max(1, Math.round(Number(w.maxDl) || 100)),
maxUl: Math.max(1, Math.round(Number(w.maxUl) || 100)),
}))
: []
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: s.model ?? "—",
os: s.os ?? "—",
site: s.site || "—",
country: s.country || "UN",
asn: s.asn,
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as ServerStatus,
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: s.sessions ?? 0,
wanUplinks,
}
}
function sumSeries(rows: TrafficServerRow[], key: "rxSeries" | "txSeries"): number[] {
const len = Math.max(60, ...rows.map((r) => r[key].length), 0)
const out = Array.from({ length: len }, () => 0)
for (const row of rows) {
const series = row[key]
for (let i = 0; i < len; i++) {
out[i] += series[i] ?? 0
}
}
return out
}
function mockOverlayItems(): OverlayItem[] {
const gre: OverlayItem[] = mockGreTunnels.map((t) => ({
id: `gre-${t.id}`,
name: t.name,
kind: "gre",
href: "/gre",
status: t.status,
}))
const wg: OverlayItem[] = mockServers.flatMap((s) =>
(s.wireGuardIfaces ?? []).map((iface) => ({
id: `wg-${iface.id}`,
name: iface.name,
kind: "wg" as const,
href: "/wireguard",
status: iface.status,
})),
)
const vx: OverlayItem[] = mockVxlan.map((t) => ({
id: `vx-${t.id}`,
name: t.name,
kind: "vxlan",
href: "/vxlan",
status: t.status,
}))
return [...gre, ...wg, ...vx]
}
export function useDashboardLive() {
const pathname = usePathname()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
const [liveServers, setLiveServers] = useState<Server[] | null>(null)
const [overlayItems, setOverlayItems] = useState<OverlayItem[] | null>(null)
const [bgp, setBgp] = useState<{ prefixSum: number; establishedCount: number } | null>(null)
const [trafficSeries, setTrafficSeries] = useState<{
rx: number[]
tx: number[]
rxNow: number
txNow: number
} | null>(null)
const [recentEvents, setRecentEvents] = useState<EventItem[]>([])
const [eventsError, setEventsError] = useState<string | null>(null)
const [eventsLoading, setEventsLoading] = useState(false)
const [internetPath, setInternetPath] = useState<InternetPathViewModel | null>(null)
const [internetPathLoading, setInternetPathLoading] = useState(false)
const [internetPathError, setInternetPathError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [mockDashEpoch, setMockDashEpoch] = useState(0)
const fetchSnapshot = useCallback(async (silent: boolean) => {
if (!isLive) return
if (!silent) {
setLoading(true)
setInternetPathLoading(true)
}
try {
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes] = await Promise.allSettled([
apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"),
apiFetch<BackendServerRow[]>("/api/servers"),
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/api/bgp/sessions"),
apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"),
apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"),
listWireGuard(backendUrl),
apiFetch<{ servers?: TrafficServerRow[] }>("/api/traffic/servers?range=1h"),
])
const hardFail = overviewRes.status === "rejected" && serversRes.status === "rejected"
if (hardFail) {
const reason = overviewRes.reason
setError(reason instanceof Error ? reason.message : "Не удалось загрузить дашборд")
} else {
setError(null)
}
setInternetPathError(null)
if (overviewRes.status === "fulfilled") {
setLiveProbes(overviewRes.value.probes)
} else {
setLiveProbes([])
}
let serversMapped: Server[] = []
if (serversRes.status === "fulfilled") {
serversMapped = serversRes.value.map(mapBackendToServer)
setLiveServers(serversMapped)
} else {
setLiveServers([])
}
if (br.status === "fulfilled") {
let prefixSum = 0
let establishedCount = 0
for (const s of br.value) {
const st = String(s.state ?? "")
if (/established/i.test(st)) {
establishedCount += 1
prefixSum += Number(s.prefixesRx ?? 0)
}
}
setBgp({ prefixSum, establishedCount })
} else {
setBgp(null)
}
const greItems: OverlayItem[] =
greRes.status === "fulfilled"
? (greRes.value.tunnels ?? []).map((t) => ({
id: `gre-${t.id}`,
name: t.name,
kind: "gre" as const,
href: "/gre",
status: t.status,
}))
: []
const wgItems: OverlayItem[] =
wgRes.status === "fulfilled"
? wgRes.value.interfaces.map((iface) => ({
id: `wg-${iface.id}`,
name: iface.name,
kind: "wg" as const,
href: "/wireguard",
status: iface.status,
}))
: []
setOverlayItems([...greItems, ...wgItems])
if (trafficRes.status === "fulfilled") {
const rows = trafficRes.value.servers ?? []
setTrafficSeries({
rx: sumSeries(rows, "rxSeries"),
tx: sumSeries(rows, "txSeries"),
rxNow: rows.reduce((n, r) => n + (r.rxNow ?? 0), 0),
txNow: rows.reduce((n, r) => n + (r.txNow ?? 0), 0),
})
} else {
setTrafficSeries(null)
}
const greMapped =
greRes.status === "fulfilled" ? (greRes.value.tunnels ?? []).map(apiGreToGreTunnel) : []
if (serversMapped.length > 0) {
const snap = ipRes.status === "fulfilled" ? ipRes.value.snapshot : null
if (snap) {
setInternetPath(
buildDashboardInternetPath({
servers: snap.servers.map(mapBackendToServer),
greTunnels: (snap.greTunnels ?? []).map(apiGreToGreTunnel),
probes: snap.speedProbes ?? [],
filtersRulesets: snap.filtersRulesets ?? [],
routeLookupByServerId: snap.routeLookupByServerId ?? {},
wanRuntimeByHomeId: snap.wanRuntimeByHomeId ?? {},
}),
)
} else {
const filterRulesets: FiltersRulesetRow[] =
fr.status === "fulfilled" ? ((fr.value.rulesets as FiltersRulesetRow[]) ?? []) : []
const homes = serversMapped.filter((s) => s.type === "home-router")
const lookups = await Promise.all(
homes.map(async (h) => ({
id: h.id,
lookup: await resolveDefaultRouteLookup(apiFetch, h.id),
})),
)
const wanRuntimeRows = await Promise.all(
homes.map(async (h) => {
try {
const rt = await apiFetch<HomeWanRuntime>(`/api/servers/${h.id}/wan-runtime`)
return { id: h.id, runtime: rt }
} catch {
return { id: h.id, runtime: null }
}
}),
)
const speedRes = await apiFetch<{ probes?: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes").catch(
() => ({ probes: [] }),
)
const lookupById = Object.fromEntries(lookups.map((x) => [x.id, x.lookup]))
const wanRuntimeById = Object.fromEntries(wanRuntimeRows.map((x) => [x.id, x.runtime]))
setInternetPath(
buildDashboardInternetPath({
servers: serversMapped,
greTunnels: greMapped,
probes: speedRes.probes ?? [],
filtersRulesets: filterRulesets,
routeLookupByServerId: lookupById,
wanRuntimeByHomeId: wanRuntimeById,
}),
)
}
} else {
setInternetPath(null)
}
} catch (e) {
const msg = e instanceof Error ? e.message : "Не удалось загрузить дашборд"
setInternetPathError(msg)
setInternetPath(null)
} finally {
if (!silent) {
setLoading(false)
setInternetPathLoading(false)
}
}
}, [apiFetch, backendUrl, isLive])
const fetchRecentEvents = useCallback(async (silent: boolean) => {
if (!isLive) {
setRecentEvents([])
setEventsError(null)
return
}
if (!silent) setEventsLoading(true)
try {
const rows = await listEvents(backendUrl, { limit: 6 })
setRecentEvents(rows)
setEventsError(null)
} catch (err) {
setRecentEvents([])
setEventsError(err instanceof Error ? err.message : "Не удалось загрузить события")
} finally {
if (!silent) setEventsLoading(false)
}
}, [backendUrl, isLive])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveProbes(null)
setLiveServers(null)
setOverlayItems(null)
setBgp(null)
setTrafficSeries(null)
setError(null)
setInternetPath(null)
setInternetPathError(null)
})
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
void fetchSnapshot(false)
})
return () => {
cancelled = true
}
}, [isLive, fetchSnapshot])
useEffect(() => {
queueMicrotask(() => {
void fetchRecentEvents(false)
})
}, [fetchRecentEvents])
useEffect(() => {
const id = setInterval(() => {
queueMicrotask(() => {
void fetchRecentEvents(true)
})
}, 20_000)
return () => clearInterval(id)
}, [fetchRecentEvents])
useEffect(() => {
if (!isLive) return
const id = setInterval(() => {
queueMicrotask(() => {
void fetchSnapshot(true)
})
}, 60_000)
return () => clearInterval(id)
}, [isLive, fetchSnapshot])
useEffect(() => {
if (!isLive) return
queueMicrotask(() => {
void fetchSnapshot(true)
})
}, [pathname, isLive, fetchSnapshot])
useEffect(() => {
const bumpMock = () => setMockDashEpoch((x) => x + 1)
const onStorage = (e: StorageEvent) => {
if (e.key === MOCK_DASH_STARS_LS) bumpMock()
}
const onVis = () => {
if (document.visibilityState === "visible") bumpMock()
}
const onUptimeChanged = () => {
bumpMock()
if (isLive) void fetchSnapshot(true)
}
window.addEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged)
window.addEventListener("storage", onStorage)
document.addEventListener("visibilitychange", onVis)
return () => {
window.removeEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged)
window.removeEventListener("storage", onStorage)
document.removeEventListener("visibilitychange", onVis)
}
}, [isLive, fetchSnapshot])
useEffect(() => {
queueMicrotask(() => setMockDashEpoch((x) => x + 1))
}, [pathname])
const servers = useMemo(() => {
if (!prefsHydrated) return []
if (!isLive) return mockServers
return liveServers ?? []
}, [prefsHydrated, isLive, liveServers])
const mockActiveProbes = useMemo(() => {
void mockDashEpoch
const stars = readMockDashboardStarIds()
return pingProbes.filter((p) => p.enabled && stars.has(p.id))
}, [mockDashEpoch])
const activeProbes = useMemo(() => {
if (!prefsHydrated) return []
if (!isLive) return mockActiveProbes
if (liveProbes === null) return []
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
}, [prefsHydrated, isLive, liveProbes, mockActiveProbes])
const enabledProbes = useMemo(() => {
if (!prefsHydrated) return []
if (!isLive) return pingProbes.filter((p) => p.enabled)
return liveProbes?.filter((p) => p.enabled) ?? []
}, [prefsHydrated, isLive, liveProbes])
const overlay = useMemo(() => {
const items = !prefsHydrated ? [] : isLive ? (overlayItems ?? []) : mockOverlayItems()
const up = items.filter((i) => i.status === "up").length
const down = items.filter((i) => i.status !== "up").length
return { items, total: items.length, up, down }
}, [prefsHydrated, isLive, overlayItems])
const traffic = useMemo(() => {
if (!prefsHydrated) return null
if (!isLive) {
return {
rx: mockTraffic.rx,
tx: mockTraffic.tx,
rxNow: mockTraffic.rx[mockTraffic.rx.length - 1] ?? 0,
txNow: mockTraffic.tx[mockTraffic.tx.length - 1] ?? 0,
demo: true,
}
}
if (!trafficSeries) return null
return { ...trafficSeries, demo: false }
}, [prefsHydrated, isLive, trafficSeries])
const latency: LatencyBlock = useMemo(() => {
if (!prefsHydrated) return { kind: "loading" }
if (!isLive) {
return {
kind: "mock",
series: dashLatency,
subtitle: "Последние 60 минут · демо",
}
}
if (liveProbes === null && loading) return { kind: "loading" }
if (!liveProbes?.length) {
return {
kind: "empty",
message: "Нет данных проб. Откройте мониторинг и проверьте сборщик uptime.",
}
}
const { series, labels } = buildLatencySeriesByProbeSource(liveProbes, liveServers ?? [], {
maxServers: 8,
points: 60,
})
if (Object.keys(series).length === 0) {
return {
kind: "empty",
message: "Нет включённых проб с историей RTT. Включите пробы на странице «Мониторинг».",
}
}
return {
kind: "live",
series,
labels,
subtitle: "Средний RTT · 1 ч · до 8 узлов",
}
}, [prefsHydrated, isLive, liveProbes, loading, liveServers])
const attentionServers: AttentionRow[] = useMemo(() => {
return servers
.filter((s) => s.enabled && s.status !== "online")
.slice(0, 5)
.map((s) => ({
id: s.id,
title: s.name,
hint: s.status === "degraded" ? "degraded" : "offline",
href: "/servers",
tone: s.status === "degraded" ? ("warning" as const) : ("destructive" as const),
}))
}, [servers])
const attentionOverlay: AttentionRow[] = useMemo(() => {
return overlay.items
.filter((i) => i.status !== "up")
.slice(0, 5)
.map((i) => ({
id: i.id,
title: i.name,
hint: i.status === "degraded" ? "degraded" : "down",
href: i.href,
tone: i.status === "degraded" ? ("warning" as const) : ("destructive" as const),
}))
}, [overlay.items])
const attentionProbes: AttentionRow[] = useMemo(() => {
return enabledProbes
.filter((p) => p.status === "down" || p.status === "warn")
.slice(0, 5)
.map((p) => ({
id: p.id,
title: p.name,
hint: p.status === "warn" ? "warn" : "down",
href: "/uptime",
tone: p.status === "warn" ? ("warning" as const) : ("destructive" as const),
}))
}, [enabledProbes])
const onlineCount = servers.filter((s) => s.status === "online").length
const probeDown = enabledProbes.filter((p) => p.status === "down").length
const probeWarn = enabledProbes.filter((p) => p.status === "warn").length
const dataPending = isLive && !error && (liveServers === null || liveProbes === null)
const kpiLoading = !prefsHydrated || dataPending
const probesSubtitle = !prefsHydrated
? "Загрузка…"
: !isLive
? mockActiveProbes.length > 0
? `${mockActiveProbes.length} на дашборде · демо`
: "Нет проб на дашборде · отметьте ★ в мониторинге"
: error && liveProbes === null
? error
: activeProbes.length > 0
? `${activeProbes.length} на дашборде · 1 ч`
: "Нет проб на дашборде · отметьте ★ в мониторинге"
return {
prefsHydrated,
isLive,
loading: kpiLoading,
error,
retry: () => {
void fetchSnapshot(false)
void fetchRecentEvents(false)
},
servers,
activeProbes,
overlay,
bgp: isLive ? bgp : { prefixSum: 8432, establishedCount: 3 },
traffic,
onlineCount,
totalServers: servers.length,
probeDown,
probeWarn,
latency,
recentEvents,
eventsLoading,
eventsError,
internetPath,
internetPathLoading: isLive && internetPathLoading && !internetPath,
internetPathError,
attentionServers,
attentionOverlay,
attentionProbes,
probesSubtitle,
probesLoading: isLive && loading && liveProbes === null,
}
}
+36
View File
@@ -0,0 +1,36 @@
import type { FilterFieldConfig } from "@/components/reui/filters"
import type { Backup } from "@/lib/data"
export const BACKUP_FILTER_FIELDS: FilterFieldConfig[] = [
{
key: "server",
label: "Сервер",
type: "text",
placeholder: "Имя сервера",
},
{
key: "kind",
label: "Тип",
type: "multiselect",
options: [
{ value: "auto", label: "авто" },
{ value: "manual", label: "вручную" },
],
},
{
key: "storage",
label: "Хранилище",
type: "multiselect",
options: [
{ value: "local", label: "Локально" },
{ value: "s3", label: "S3" },
{ value: "both", label: "Локально + S3" },
],
},
]
export const BACKUP_FILTER_ACCESSORS = {
server: (b: Backup) => b.server,
kind: (b: Backup) => b.kind,
storage: (b: Backup) => b.storage,
}
+8 -5
View File
@@ -189,11 +189,14 @@ export interface FirewallAddressListEntry {
export interface Backup {
id: string
server: string
serverId?: string | null
filename: string
size: string
created: string
kind: "auto" | "manual"
notes: string
storage: "local" | "s3" | "both"
uploadError?: string
}
// ─── VXLAN ───────────────────────────────────────────────────────────────────
@@ -467,11 +470,11 @@ export const firewallRules: FirewallRule[] = [
]
export const backups: Backup[] = [
{ id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением" },
{ id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" },
{ id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "" },
{ id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP" },
{ id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" },
{ id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением", storage: "local" },
{ id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" },
{ id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "", storage: "s3" },
{ id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP", storage: "both" },
{ id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" },
]
export const pingProbes: PingProbe[] = [
+14
View File
@@ -97,6 +97,19 @@ export interface BackupsRunSnapshot {
fatalError?: string
}
export interface GeoipUpdateRunSnapshot {
v: number
job: "geoip_update"
sampledAt: string
skipped?: boolean
fatalError?: string
checked: number
downloaded: number
skippedUnchanged: number
bytes: number
errors: string[]
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
@@ -107,6 +120,7 @@ export type SchedulerRunSnapshot =
| InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| GeoipUpdateRunSnapshot
| AlertEngineRunSnapshot
export interface TrafficServerSnapshot {
+5 -1
View File
@@ -11,6 +11,7 @@ export const SCHEDULER_JOB_KEYS = [
"gre_bgp",
"certificates_renew",
"backups",
"geoip_update",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
@@ -25,6 +26,7 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
gre_bgp: "GRE + BGP",
certificates_renew: "Сертификаты: автообновление",
backups: "Бэкапы",
geoip_update: "GeoIP: базы GeoLite2",
alert_engine: "Оповещения",
}
@@ -40,9 +42,11 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
gre_bgp:
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
certificates_renew:
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
"Автообновление сертификатов, выпущенных через UI (ACME DNS-01 / Cloudflare). Отключается на странице «Сертификаты», если ACME ведёт RouterOS.",
backups:
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
geoip_update:
"Проверка и доставка GeoLite2 Country/ASN с зеркала P3TERX в backend/storage/geoip (ETag, атомарная подмена). Управление — в настройках NetFlow.",
alert_engine:
"Оценка правил по данным из PostgreSQL (сэмплы пишут джобы сбора, в т.ч. «GRE + BGP» и «Серверы: REST API»).",
}
+430
View File
@@ -50,6 +50,7 @@
"name": "mikrotik-manager-backend",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
@@ -60,6 +61,7 @@
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"maxmind": "^5.0.7",
"pg": "^8.23.0",
"undici": "^8.1.0",
"zod": "^4.4.1"
@@ -110,6 +112,314 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.29",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz",
"integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1127.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1127.0.tgz",
"integrity": "sha512-0ZSAgmEda33xPqVPt+bx2KzrXV1cUCKRRVPGliLu+V7DzHPa04CqPSi1maGEM4O0LDP0iI6HRSW5ULloNwayNw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.29",
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/credential-provider-node": "^3.972.82",
"@aws-sdk/middleware-sdk-s3": "^3.972.75",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.977.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz",
"integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.5",
"@aws-sdk/xml-builder": "^3.972.40",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.33.3",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.17.2",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.70",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz",
"integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz",
"integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.15",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz",
"integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/credential-provider-env": "^3.972.70",
"@aws-sdk/credential-provider-http": "^3.972.72",
"@aws-sdk/credential-provider-login": "^3.972.77",
"@aws-sdk/credential-provider-process": "^3.972.70",
"@aws-sdk/credential-provider-sso": "^3.973.14",
"@aws-sdk/credential-provider-web-identity": "^3.972.76",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.77",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz",
"integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.82",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz",
"integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.70",
"@aws-sdk/credential-provider-http": "^3.972.72",
"@aws-sdk/credential-provider-ini": "^3.973.15",
"@aws-sdk/credential-provider-process": "^3.972.70",
"@aws-sdk/credential-provider-sso": "^3.973.14",
"@aws-sdk/credential-provider-web-identity": "^3.972.76",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.70",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz",
"integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.14",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz",
"integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/token-providers": "3.1116.0",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz",
"integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.75",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz",
"integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.44",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz",
"integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz",
"integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.5",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1116.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz",
"integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz",
"integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.40",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz",
"integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -3759,6 +4069,87 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@smithy/core": {
"version": "3.33.3",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz",
"integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz",
"integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz",
"integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -5565,6 +5956,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
@@ -9990,6 +10387,20 @@
"node": ">= 0.4"
}
},
"node_modules/maxmind": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.7.tgz",
"integrity": "sha512-+w637dwfv01MKjkrp4sKDBTEKHLPvWLYb647QTjiz3wG/teSemqudIKNShaS6eqZ7ffxC9oZlQQgIqY0rGojog==",
"license": "MIT",
"dependencies": {
"mmdb-lib": "3.0.3",
"tiny-lru": "13.0.0"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -10135,6 +10546,16 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mmdb-lib": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.3.tgz",
"integrity": "sha512-xQPoBXcNjjHiOvOraFBKtA++uNWF6aCVHL9dRKFXEov8eI3QJwtgiw3qApsonFT5SpoqsEVISUTg3HIDs2DiXw==",
"license": "MIT",
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/mnemonist": {
"version": "0.40.4",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
@@ -12903,6 +13324,15 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tiny-lru": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-13.0.0.tgz",
"integrity": "sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=14"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+4
View File
@@ -45,6 +45,10 @@
"./traffic-flow": {
"types": "./dist/traffic-flow.d.ts",
"default": "./dist/traffic-flow.js"
},
"./geoip": {
"types": "./dist/geoip.d.ts",
"default": "./dist/geoip.js"
}
},
"dependencies": {
+47
View File
@@ -2,6 +2,8 @@ import { z } from "zod"
export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"])
export const backupFormatSchema = z.enum(["rsc", "backup"])
export const backupStorageProviderSchema = z.enum(["local", "s3"])
export const backupObjectStorageSchema = z.enum(["local", "s3", "both"])
export const backupScheduleSettingsDtoSchema = z.object({
enabled: z.boolean(),
@@ -32,3 +34,48 @@ export const putBackupScheduleSettingsSchema = z.object({
})
export type BackupScheduleSettingsDto = z.infer<typeof backupScheduleSettingsDtoSchema>
export const backupStorageSettingsDtoSchema = z.object({
provider: backupStorageProviderSchema,
s3Endpoint: z.string(),
s3Region: z.string(),
s3Bucket: z.string(),
s3Prefix: z.string(),
s3AccessKeyId: z.string(),
secretConfigured: z.boolean(),
s3ForcePathStyle: z.boolean(),
keepLocalCopy: z.boolean(),
lastTestAt: z.string().nullable().optional(),
lastTestError: z.string().nullable().optional(),
updatedAt: z.string().optional(),
})
export const putBackupStorageSettingsSchema = z.object({
provider: backupStorageProviderSchema.optional(),
s3Endpoint: z.string().optional(),
s3Region: z.string().optional(),
s3Bucket: z.string().optional(),
s3Prefix: z.string().optional(),
s3AccessKeyId: z.string().optional(),
s3SecretAccessKey: z.string().optional(),
s3ForcePathStyle: z.boolean().optional(),
keepLocalCopy: z.boolean().optional(),
})
export const backupItemDtoSchema = z.object({
id: z.string(),
serverId: z.number().nullable(),
serverName: z.string(),
filename: z.string(),
sizeBytes: z.number(),
createdAt: z.string(),
kind: z.enum(["manual", "auto"]),
notes: z.string().optional(),
storage: backupObjectStorageSchema,
s3Key: z.string().nullable().optional(),
uploadError: z.string().nullable().optional(),
})
export type BackupStorageSettingsDto = z.infer<typeof backupStorageSettingsDtoSchema>
export type PutBackupStorageSettings = z.infer<typeof putBackupStorageSettingsSchema>
export type BackupItemDto = z.infer<typeof backupItemDtoSchema>
+51
View File
@@ -0,0 +1,51 @@
import { z } from "zod"
export const geoipSettingsDtoSchema = z.object({
enabled: z.boolean(),
updateIntervalSec: z.number().int().positive(),
lastCheckAt: z.string().nullable(),
lastSuccessAt: z.string().nullable(),
lastError: z.string().nullable(),
countryBuildAt: z.string().nullable(),
asnBuildAt: z.string().nullable(),
updatedAt: z.string(),
})
export const geoipSettingsPatchSchema = z.object({
enabled: z.boolean().optional(),
updateIntervalSec: z
.number()
.int()
.min(6 * 3600)
.max(30 * 86400)
.optional(),
})
export const geoipStatusDtoSchema = z.object({
ready: z.boolean(),
countryLoaded: z.boolean(),
asnLoaded: z.boolean(),
countryFile: z.string(),
asnFile: z.string(),
dir: z.string(),
running: z.boolean(),
settings: geoipSettingsDtoSchema,
})
export const geoipUpdateSnapshotDtoSchema = z.object({
v: z.number(),
job: z.literal("geoip_update"),
sampledAt: z.string(),
skipped: z.boolean().optional(),
fatalError: z.string().optional(),
checked: z.number().int(),
downloaded: z.number().int(),
skippedUnchanged: z.number().int(),
bytes: z.number().int().nonnegative(),
errors: z.array(z.string()),
})
export type GeoipSettingsDto = z.infer<typeof geoipSettingsDtoSchema>
export type GeoipSettingsPatch = z.infer<typeof geoipSettingsPatchSchema>
export type GeoipStatusDto = z.infer<typeof geoipStatusDtoSchema>
export type GeoipUpdateSnapshotDto = z.infer<typeof geoipUpdateSnapshotDtoSchema>
+1
View File
@@ -6,3 +6,4 @@ export * from "./backups.js"
export * from "./wireguard.js"
export * from "./users.js"
export * from "./traffic-flow.js"
export * from "./geoip.js"
+41 -2
View File
@@ -1,15 +1,22 @@
import { requestJson } from "@/shared/api/http-client"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import type {
BackupScheduleSettingsDto,
BackupStorageSettingsDto,
PutBackupStorageSettings,
} from "@mmapp/contracts/backups"
export type BackupItem = {
id: string
serverId: string
serverId: number | string | null
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual" | "auto"
notes?: string
storage?: "local" | "s3" | "both"
s3Key?: string | null
uploadError?: string | null
}
type CreateBackupResponse = {
@@ -68,6 +75,12 @@ export async function deleteBackup(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/backups/${id}`, { method: "DELETE" })
}
export async function restoreBackup(baseUrl: string, id: string): Promise<{ filename: string; serverName: string }> {
return requestJson<{ filename: string; serverName: string }>(baseUrl, `/api/backups/${id}/restore`, {
method: "POST",
})
}
export async function getBackupScheduleSettings(baseUrl: string): Promise<BackupScheduleSettingsDto> {
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule")
}
@@ -81,3 +94,29 @@ export async function putBackupScheduleSettings(
body: JSON.stringify(payload),
})
}
export async function getBackupStorageSettings(baseUrl: string): Promise<BackupStorageSettingsDto> {
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage")
}
export async function putBackupStorageSettings(
baseUrl: string,
payload: PutBackupStorageSettings,
): Promise<BackupStorageSettingsDto> {
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage", {
method: "PUT",
body: JSON.stringify(payload),
})
}
export async function testBackupStorage(baseUrl: string): Promise<BackupStorageSettingsDto> {
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage/test", { method: "POST" })
}
export async function syncBackupsFromStorage(
baseUrl: string,
): Promise<{ imported: number; skipped: number }> {
return requestJson<{ imported: number; skipped: number }>(baseUrl, "/api/backups/storage/sync", {
method: "POST",
})
}
+27
View File
@@ -0,0 +1,27 @@
import type {
GeoipSettingsPatch,
GeoipStatusDto,
GeoipUpdateSnapshotDto,
} from "@mmapp/contracts/geoip"
import { requestJson } from "@/shared/api/http-client"
export async function getGeoipStatus(baseUrl: string): Promise<GeoipStatusDto> {
return requestJson<GeoipStatusDto>(baseUrl, "/api/geoip")
}
export async function putGeoipSettings(
baseUrl: string,
patch: GeoipSettingsPatch,
): Promise<{ ok: boolean; status: GeoipStatusDto }> {
return requestJson(baseUrl, "/api/geoip", {
method: "PUT",
body: JSON.stringify(patch),
})
}
export async function runGeoipUpdateNow(baseUrl: string): Promise<{
ok: boolean
snapshot: GeoipUpdateSnapshotDto
}> {
return requestJson(baseUrl, "/api/geoip/update", { method: "POST" })
}
+1 -1
View File
File diff suppressed because one or more lines are too long