Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d39e3454aa | ||
|
|
a8f2055c77 | ||
|
|
cdeb97d841 | ||
|
|
36c5305db7 | ||
|
|
5f2b4e2d40 | ||
|
|
cff26813b9 |
+309
-583
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import { OpsPanel } from "@/components/ops-panel"
|
|||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
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 { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
@@ -243,7 +244,7 @@ function CertPartReference() {
|
|||||||
return (
|
return (
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="RouterOS 7 · /certificate — справка CLI"
|
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"
|
contentClassName="px-5 py-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<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} />
|
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||||
|
|
||||||
|
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
|
||||||
|
|
||||||
{liveReady && (
|
{liveReady && (
|
||||||
<CertPartAcmeSettings
|
<CertPartAcmeSettings
|
||||||
acmeDirectoryUrl={acmeDirectoryUrl}
|
acmeDirectoryUrl={acmeDirectoryUrl}
|
||||||
|
|||||||
+260
-851
File diff suppressed because it is too large
Load Diff
@@ -1127,7 +1127,11 @@ export default function DataCollectionPage() {
|
|||||||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
disabled={!draftCertRenewEnabled}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
@@ -18,9 +18,11 @@
|
|||||||
"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",
|
||||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.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: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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.2.2",
|
"@fastify/jwt": "^10.2.2",
|
||||||
"@fastify/type-provider-zod": "^1.0.0",
|
"@fastify/type-provider-zod": "^1.0.0",
|
||||||
|
|||||||
@@ -420,6 +420,22 @@ export const backupScheduleSettings = pgTable("backup_schedule_settings", {
|
|||||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
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", {
|
export const backupEntries = pgTable("backup_entries", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
serverId: bigint("server_id", { mode: "number" })
|
serverId: bigint("server_id", { mode: "number" })
|
||||||
@@ -429,6 +445,10 @@ export const backupEntries = pgTable("backup_entries", {
|
|||||||
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
||||||
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
|
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
|
||||||
notes: text("notes"),
|
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(),
|
createdAt: ts("created_at").notNull(),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
uniqueIndex("idx_backup_entries_filename").on(t.filename),
|
uniqueIndex("idx_backup_entries_filename").on(t.filename),
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ const TABLES: TableCopy[] = [
|
|||||||
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
|
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
|
||||||
["last_error", "text"], ["updated_at", "ts"],
|
["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: [
|
{ table: "internet_path_settings", upsert: true, columns: [
|
||||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
|
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
|
||||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { readFile } from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-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 { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
import { refreshScheduler } from "../services/scheduler.js"
|
import { refreshScheduler } from "../services/scheduler.js"
|
||||||
import {
|
import {
|
||||||
deleteBackupRecord,
|
deleteBackupRecord,
|
||||||
getBackupById,
|
getBackupById,
|
||||||
getBackupsDir,
|
|
||||||
getBackupScheduleSettings,
|
getBackupScheduleSettings,
|
||||||
|
getBackupStorageSettings,
|
||||||
listBackups,
|
listBackups,
|
||||||
|
readBackupContent,
|
||||||
|
restoreBackupToDevice,
|
||||||
runBackupForServer,
|
runBackupForServer,
|
||||||
|
syncBackupsFromS3,
|
||||||
|
testBackupStorageConnection,
|
||||||
updateBackupScheduleSettings,
|
updateBackupScheduleSettings,
|
||||||
|
updateBackupStorageSettings,
|
||||||
type BackupMeta,
|
type BackupMeta,
|
||||||
} from "../services/backup-service.js"
|
} from "../services/backup-service.js"
|
||||||
|
|
||||||
@@ -97,6 +103,39 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
return reply.send(result)
|
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) => {
|
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
|
||||||
const inputIds = req.body.serverIds.map((x) => String(x))
|
const inputIds = req.body.serverIds.map((x) => String(x))
|
||||||
const notes = req.body.notes?.trim() || undefined
|
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) => {
|
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||||
const hit = await getBackupById(req.params.id)
|
const hit = await getBackupById(req.params.id)
|
||||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||||
const filePath = path.join(getBackupsDir(), hit.filename)
|
try {
|
||||||
const content = await readFile(filePath, "utf8").catch(() => null)
|
const content = await readBackupContent(hit)
|
||||||
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
|
reply.header("Content-Type", "text/plain; charset=utf-8")
|
||||||
reply.header("Content-Type", "text/plain; charset=utf-8")
|
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
|
||||||
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
|
return reply.send(content)
|
||||||
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) => {
|
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
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 path from "node:path"
|
||||||
import { desc, eq } from "drizzle-orm"
|
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 { db } from "../db/index.js"
|
||||||
import { parseJsonArray } from "../db/json.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 { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
||||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||||
import { MikrotikClient } from "./mikrotik.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 SETTINGS_ID = 1
|
||||||
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
||||||
@@ -22,9 +38,14 @@ export type BackupMeta = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
kind: "manual" | "auto"
|
kind: "manual" | "auto"
|
||||||
notes?: string
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
serverId: row.serverId,
|
serverId: row.serverId,
|
||||||
@@ -34,6 +55,9 @@ function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
|
|||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
notes: row.notes ?? undefined,
|
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,
|
sizeBytes: meta.sizeBytes,
|
||||||
kind: meta.kind,
|
kind: meta.kind,
|
||||||
notes: meta.notes ?? null,
|
notes: meta.notes ?? null,
|
||||||
|
storage: meta.storage,
|
||||||
|
s3Key: meta.s3Key ?? null,
|
||||||
|
s3Etag: null,
|
||||||
|
uploadError: meta.uploadError ?? null,
|
||||||
createdAt: meta.createdAt,
|
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> {
|
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||||
const hit = await getBackupById(id)
|
const row = await getBackupRow(id)
|
||||||
if (!hit) return null
|
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 db.delete(backupEntries).where(eq(backupEntries.id, id))
|
||||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
await rm(path.join(BACKUPS_DIR, row.filename), { force: true })
|
||||||
return hit
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTs(d = new Date()): string {
|
function fmtTs(d = new Date()): string {
|
||||||
@@ -167,6 +211,127 @@ export async function touchBackupScheduleRunMeta(patch: {
|
|||||||
}).where(eq(backupScheduleSettings.id, SETTINGS_ID))
|
}).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[]> {
|
export async function resolveBackupServerIds(settings: BackupScheduleSettingsDto): Promise<string[]> {
|
||||||
const enabled = new Set((await listServersRead()).map((s) => String(s.id)))
|
const enabled = new Set((await listServersRead()).map((s) => String(s.id)))
|
||||||
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
|
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
|
||||||
@@ -214,6 +379,23 @@ export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, last
|
|||||||
return true
|
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(
|
export async function runBackupForServer(
|
||||||
id: string,
|
id: string,
|
||||||
kind: BackupMeta["kind"],
|
kind: BackupMeta["kind"],
|
||||||
@@ -230,12 +412,33 @@ export async function runBackupForServer(
|
|||||||
const client = MikrotikClient.fromServer(row)
|
const client = MikrotikClient.fromServer(row)
|
||||||
const script = await client.exportConfigScript()
|
const script = await client.exportConfigScript()
|
||||||
const ts = fmtTs()
|
const ts = fmtTs()
|
||||||
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
const safeServer = sanitizeServerName(row.name)
|
||||||
const filename = `${safeServer}_${ts}.rsc`
|
const filename = `${safeServer}_${ts}.rsc`
|
||||||
const filePath = path.join(BACKUPS_DIR, filename)
|
const filePath = path.join(BACKUPS_DIR, filename)
|
||||||
await ensureBackupStorage()
|
await ensureBackupStorage()
|
||||||
await writeFile(filePath, script, "utf8")
|
await writeFile(filePath, script, "utf8")
|
||||||
const st = await stat(filePath)
|
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 = {
|
const meta: BackupMeta = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
serverId: row.id,
|
serverId: row.id,
|
||||||
@@ -245,8 +448,24 @@ export async function runBackupForServer(
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
kind,
|
kind,
|
||||||
notes,
|
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
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,12 +476,82 @@ export async function pruneBackupsForServer(serverId: string, keepCount: number)
|
|||||||
if (rows.length <= keepCount) return 0
|
if (rows.length <= keepCount) return 0
|
||||||
const toDelete = rows.slice(keepCount)
|
const toDelete = rows.slice(keepCount)
|
||||||
for (const hit of toDelete) {
|
for (const hit of toDelete) {
|
||||||
await db.delete(backupEntries).where(eq(backupEntries.id, hit.id))
|
await deleteBackupRecord(hit.id)
|
||||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
|
||||||
}
|
}
|
||||||
return toDelete.length
|
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 {
|
export function getBackupsDir(): string {
|
||||||
return BACKUPS_DIR
|
return BACKUPS_DIR
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stillOn = await getCertificateRenewSettings()
|
||||||
|
if (!stillOn.enabled) {
|
||||||
|
item.action = "skipped"
|
||||||
|
item.message = "Автообновление выключено"
|
||||||
|
snapshot.skippedTargets += 1
|
||||||
|
snapshot.targets?.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const jobId = randomUUID()
|
const jobId = randomUUID()
|
||||||
await createIssueJobRecord({
|
await createIssueJobRecord({
|
||||||
id: jobId,
|
id: jobId,
|
||||||
|
|||||||
@@ -586,6 +586,14 @@ export class MikrotikClient {
|
|||||||
: new Error(`Не удалось загрузить файл ${normalized} на RouterOS`)
|
: 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: {
|
async importCertificate(params: {
|
||||||
fileName: string
|
fileName: string
|
||||||
name: 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")
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import {
|
import {
|
||||||
brandByAsn,
|
brandByAsn,
|
||||||
|
brandByHolder,
|
||||||
countryFromHolder,
|
countryFromHolder,
|
||||||
|
isSteamGamePort,
|
||||||
lookupBrand,
|
lookupBrand,
|
||||||
OTHER_SERVICE,
|
OTHER_SERVICE,
|
||||||
isNamedInternetService,
|
isNamedInternetService,
|
||||||
mapServiceNodeId,
|
mapServiceNodeId,
|
||||||
|
resolveFlowBrand,
|
||||||
resolveRipeCountry,
|
resolveRipeCountry,
|
||||||
} from "./traffic-flow-brands.js"
|
} from "./traffic-flow-brands.js"
|
||||||
|
|
||||||
@@ -39,4 +42,35 @@ assert.equal(isNamedInternetService("DNS", "DNS"), false)
|
|||||||
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
|
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
|
||||||
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
|
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")
|
console.log("traffic-flow-brands.test.ts: ok")
|
||||||
|
|||||||
@@ -7,59 +7,162 @@ export interface BrandHit {
|
|||||||
category: string
|
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>([
|
const ASN_BRANDS = new Map<number, BrandHit>([
|
||||||
[13335, { service: "Cloudflare", category: "CDN" }],
|
...brandEntries(CLOUDFLARE, [13335, 209242]),
|
||||||
[209242, { service: "Cloudflare", category: "CDN" }],
|
...brandEntries(FASTLY, [54113]),
|
||||||
[54113, { service: "Fastly", category: "CDN" }],
|
...brandEntries(AKAMAI, [20940, 16625, 32787, 35994, 16702, 24319]),
|
||||||
[20940, { service: "Akamai", category: "CDN" }],
|
...brandEntries(AWS, [16509, 14618, 8987, 7224, 9059]),
|
||||||
[16509, { service: "AWS", category: "CDN" }],
|
...brandEntries(MICROSOFT, [8075, 8068, 8069, 8070]),
|
||||||
[14618, { service: "AWS", category: "CDN" }],
|
...brandEntries(YANDEX, [13238]),
|
||||||
[8075, { service: "Microsoft", category: "CDN" }],
|
...brandEntries(APPLE, [714, 6185]),
|
||||||
[13238, { service: "Yandex", category: "CDN" }],
|
...brandEntries(DIGITALOCEAN, [14061]),
|
||||||
[32590, { service: "Steam", category: "Игры" }],
|
...brandEntries(HETZNER, [24940, 213230]),
|
||||||
[57976, { service: "Blizzard", category: "Игры" }],
|
...brandEntries(OVH, [16276]),
|
||||||
[2906, { service: "Netflix", category: "Видео / стриминг" }],
|
...brandEntries(ORACLE, [31898]),
|
||||||
[40027, { service: "Netflix", category: "Видео / стриминг" }],
|
...brandEntries(LINODE, [63949]),
|
||||||
[15169, { service: "Google", category: "Веб" }],
|
...brandEntries(VULTR, [20473]),
|
||||||
[36040, { service: "YouTube", category: "Видео / стриминг" }],
|
...brandEntries(SCALEWAY, [12876]),
|
||||||
[46489, { service: "Twitch", category: "Видео / стриминг" }],
|
...brandEntries(IBM_CLOUD, [36351]),
|
||||||
[401115, { service: "ChatGPT", category: "ИИ" }],
|
...brandEntries(ALIBABA, [45102]),
|
||||||
[49544, { service: "Discord", category: "Голос" }],
|
...brandEntries(TENCENT, [132203]),
|
||||||
[62041, { service: "Telegram", category: "Голос" }],
|
...brandEntries(GCORE, [199524]),
|
||||||
[59930, { service: "Telegram", category: "Голос" }],
|
...brandEntries(CDN77, [60068]),
|
||||||
[211157, { service: "Telegram", category: "Голос" }],
|
...brandEntries(SELECTEL, [50340, 49505]),
|
||||||
[32934, { service: "Meta", category: "CDN" }],
|
...brandEntries(TIMEWEB, [9123]),
|
||||||
[396986, { service: "TikTok", category: "Видео / стриминг" }],
|
...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>([
|
const ASN_HQ_COUNTRY = new Map<number, string>([
|
||||||
[13335, "US"],
|
...hqEntries("US", [
|
||||||
[209242, "US"],
|
13335, 209242, 54113, 20940, 16625, 32787, 35994, 16702, 24319,
|
||||||
[54113, "US"],
|
16509, 14618, 8987, 7224, 8075, 8068, 8069, 8070, 15169, 396982,
|
||||||
[20940, "US"],
|
32590, 57976, 2906, 40027, 36040, 43515, 46489, 401115, 400645,
|
||||||
[16509, "US"],
|
49544, 394141, 32934, 63293, 54115, 714, 6185, 36459, 54876, 14061,
|
||||||
[14618, "US"],
|
31898, 63949, 20473, 36351, 13414, 14413, 40793, 394706, 19679, 19750,
|
||||||
[8075, "US"],
|
14907, 17012, 26101, 14340, 30103, 36692, 395701, 6507, 62830, 33353, 22697,
|
||||||
[15169, "US"],
|
]),
|
||||||
[32590, "US"],
|
...hqEntries("IE", [9059]),
|
||||||
[57976, "US"],
|
...hqEntries("SG", [138699]),
|
||||||
[2906, "US"],
|
...hqEntries("DE", [24940, 213230]),
|
||||||
[40027, "US"],
|
...hqEntries("FR", [16276, 12876, 197922]),
|
||||||
[36040, "US"],
|
...hqEntries("CN", [45102, 132203]),
|
||||||
[46489, "US"],
|
...hqEntries("LU", [199524]),
|
||||||
[401115, "US"],
|
...hqEntries("CZ", [60068]),
|
||||||
[49544, "US"],
|
...hqEntries("RU", [13238, 50340, 49505, 9123, 198610, 57724, 47541, 47764]),
|
||||||
[32934, "US"],
|
...hqEntries("SE", [8403, 34081]),
|
||||||
[13238, "RU"],
|
...hqEntries("NL", [62041, 59930, 211157]),
|
||||||
[62041, "NL"],
|
...hqEntries("CH", [19281]),
|
||||||
[59930, "NL"],
|
|
||||||
[211157, "NL"],
|
|
||||||
])
|
])
|
||||||
|
|
||||||
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 }> = [
|
const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
|
||||||
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
|
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
|
||||||
{ cidr: "104.24.0.0/14", prefixLen: 14, 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 },
|
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
|
||||||
].sort((a, b) => b.prefixLen - a.prefixLen)
|
].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 NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
|
||||||
|
|
||||||
|
const STEAM_ASN = 32590
|
||||||
|
|
||||||
export function isIsoCountry(code: string): boolean {
|
export function isIsoCountry(code: string): boolean {
|
||||||
const c = String(code ?? "").trim().toUpperCase()
|
const c = String(code ?? "").trim().toUpperCase()
|
||||||
return /^[A-Z]{2}$/.test(c) && !NON_ISO.has(c)
|
return /^[A-Z]{2}$/.test(c) && !NON_ISO.has(c)
|
||||||
@@ -114,10 +234,51 @@ export function brandByCidr(ip: string): BrandHit | null {
|
|||||||
return 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 {
|
export function lookupBrand(ip: string, asn: number): BrandHit | null {
|
||||||
return brandByCidr(ip) || brandByAsn(asn)
|
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([
|
const SKIP_MAP_SERVICES = new Set([
|
||||||
OTHER_SERVICE,
|
OTHER_SERVICE,
|
||||||
"GRE",
|
"GRE",
|
||||||
|
|||||||
@@ -53,6 +53,71 @@ const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, {
|
|||||||
assert.equal(youtube.service, "YouTube")
|
assert.equal(youtube.service, "YouTube")
|
||||||
assert.equal(youtube.category, "Видео / стриминг")
|
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)
|
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
|
||||||
assert.equal(gre.service, "GRE")
|
assert.equal(gre.service, "GRE")
|
||||||
assert.equal(gre.category, "Туннель")
|
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 { db } from "../db/index.js"
|
||||||
import { evobgpSettings } from "../db/schema.js"
|
import { evobgpSettings } from "../db/schema.js"
|
||||||
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.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 {
|
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
|
||||||
const p = purpose.toLowerCase()
|
const p = purpose.toLowerCase()
|
||||||
if (/gaming|steam|epic|riot/.test(p)) return "Игры"
|
if (/gaming|steam|epic|riot|playstation|roblox|ubisoft/.test(p)) return "Игры"
|
||||||
if (/streaming|youtube|netflix|twitch|video/.test(p)) return "Видео / стриминг"
|
if (/streaming|youtube|netflix|twitch|video|spotify/.test(p)) return "Видео / стриминг"
|
||||||
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
|
if (/cdn|cloudflare|akamai|fastly|hetzner|ovh|apple/.test(p)) return "CDN"
|
||||||
if (/voip|discord|zoom/.test(p)) return "Голос"
|
if (/voip|discord|zoom/.test(p)) return "Голос"
|
||||||
if (/openai|chatgpt|\bai\b/.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)
|
const app = applicationName(proto, dstPort, srcPort)
|
||||||
if (app === "DNS" || app === "SSH" || app === "BGP") return app
|
if (app === "DNS" || app === "SSH" || app === "BGP") return app
|
||||||
if (app === "GRE" || app === "ESP" || app === "WireGuard") return "Туннель"
|
if (app === "GRE" || app === "ESP" || app === "WireGuard") return "Туннель"
|
||||||
@@ -79,10 +80,7 @@ export function classifyFlowDst(
|
|||||||
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
|
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
|
||||||
const hit = matchCidr(dst)
|
const hit = matchCidr(dst)
|
||||||
const holder = ripe?.holder ?? ""
|
const holder = ripe?.holder ?? ""
|
||||||
const youtubeHolder = /youtube/i.test(holder)
|
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, holder, proto, dstPort, srcPort)
|
||||||
const brand = youtubeHolder
|
|
||||||
? { service: "YouTube", category: "Видео / стриминг" }
|
|
||||||
: lookupBrand(dst, ripe?.asn ?? 0)
|
|
||||||
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
|
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
|
||||||
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
|
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
|
||||||
const category = hit
|
const category = hit
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
ingestParsedFlowsForServerForTests,
|
ingestParsedFlowsForServerForTests,
|
||||||
resetFlowRingsForTests,
|
resetFlowRingsForTests,
|
||||||
} from "./traffic-flow-ingest.js"
|
} 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 { withPgOrSkip } from "../test/pg.js"
|
||||||
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||||
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||||
@@ -15,6 +21,55 @@ import {
|
|||||||
seedRipeCacheForTests,
|
seedRipeCacheForTests,
|
||||||
} from "./traffic-flow-ripe.js"
|
} 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())) {
|
if (!(await withPgOrSkip())) {
|
||||||
console.log("traffic-flow-map-hops.test.ts: skip")
|
console.log("traffic-flow-map-hops.test.ts: skip")
|
||||||
process.exit(0)
|
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) {
|
function payloadFlow(dst: string, bytes: number) {
|
||||||
return {
|
return {
|
||||||
src: "10.100.1.17",
|
src: "10.100.1.17",
|
||||||
@@ -241,10 +309,106 @@ try {
|
|||||||
resetFlowMapHopsCacheForTests()
|
resetFlowMapHopsCacheForTests()
|
||||||
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
assert.equal(four.totalBytes, 10_000)
|
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()
|
resetFlowMapHopsCacheForTests()
|
||||||
const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
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 {
|
} finally {
|
||||||
resetFlowRingsForTests()
|
resetFlowRingsForTests()
|
||||||
resetIfaceCacheForTests()
|
resetIfaceCacheForTests()
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { userInterfaceBindings } from "../db/schema.js"
|
|||||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||||
import {
|
import {
|
||||||
isNamedInternetService,
|
isNamedInternetService,
|
||||||
lookupBrand,
|
|
||||||
mapServiceNodeId,
|
mapServiceNodeId,
|
||||||
|
resolveFlowBrand,
|
||||||
} from "./traffic-flow-brands.js"
|
} from "./traffic-flow-brands.js"
|
||||||
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
||||||
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
|
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
|
||||||
@@ -20,6 +20,8 @@ import { flowDataEpoch } from "./traffic-flow-engine.js"
|
|||||||
|
|
||||||
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
||||||
export const MAP_SERVICE_NODE_CAP = 20
|
export const MAP_SERVICE_NODE_CAP = 20
|
||||||
|
/** Минимум узлов-брендов на карте, даже если доля ниже порога. */
|
||||||
|
export const MAP_SERVICE_MIN_NODES = 8
|
||||||
const HOPS_CACHE_TTL_MS = 2000
|
const HOPS_CACHE_TTL_MS = 2000
|
||||||
|
|
||||||
export interface FlowMapHopsQuery {
|
export interface FlowMapHopsQuery {
|
||||||
@@ -99,6 +101,15 @@ export function clampMapServiceMinSharePct(n: unknown): number {
|
|||||||
return Math.min(100, Math.max(0, v))
|
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 {
|
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
epoch: flowDataEpoch(),
|
epoch: flowDataEpoch(),
|
||||||
@@ -174,10 +185,7 @@ function classifyMapDstLite(
|
|||||||
if (proto === 47 || proto === 50) return null
|
if (proto === 47 || proto === 50) return null
|
||||||
const app = applicationName(proto, dstPort, srcPort)
|
const app = applicationName(proto, dstPort, srcPort)
|
||||||
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
|
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
|
||||||
if (/youtube/i.test(ripe?.holder ?? "")) {
|
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
|
||||||
return { service: "YouTube", category: "Видео / стриминг" }
|
|
||||||
}
|
|
||||||
const brand = lookupBrand(dst, ripe?.asn ?? 0)
|
|
||||||
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
|
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
|
||||||
return brand
|
return brand
|
||||||
}
|
}
|
||||||
@@ -439,21 +447,20 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const minShare = minSharePct / 100
|
const namedBytes = [...svcTotals.values()].reduce((n, s) => n + s.bytes, 0)
|
||||||
let services: FlowMapService[] = [...svcTotals.entries()]
|
const services = pickMapServices(
|
||||||
.map(([id, s]) => ({
|
[...svcTotals.entries()]
|
||||||
id,
|
.map(([id, s]) => ({
|
||||||
label: s.label,
|
id,
|
||||||
category: s.category,
|
label: s.label,
|
||||||
bytes: s.bytes,
|
category: s.category,
|
||||||
bps: (s.bytes * 8) / windowSec,
|
bytes: s.bytes,
|
||||||
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
|
bps: (s.bytes * 8) / windowSec,
|
||||||
}))
|
share: namedBytes > 0 ? s.bytes / namedBytes : 0,
|
||||||
.sort((a, b) => b.bytes - a.bytes)
|
}))
|
||||||
if (minSharePct > 0) {
|
.sort((a, b) => b.bytes - a.bytes),
|
||||||
services = services.filter((s) => s.share >= minShare)
|
minSharePct,
|
||||||
}
|
)
|
||||||
services = services.slice(0, MAP_SERVICE_NODE_CAP)
|
|
||||||
const keepSvc = new Set(services.map((s) => s.id))
|
const keepSvc = new Set(services.map((s) => s.id))
|
||||||
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
||||||
.filter((e) => keepSvc.has(e.toId))
|
.filter((e) => keepSvc.has(e.toId))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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="1–28">
|
||||||
|
<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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||||
import { Maximize2Icon, ZoomInIcon, ZoomOutIcon } from "lucide-react"
|
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 [zoom, setZoom] = useState(1)
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
@@ -226,22 +224,7 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OpsPanel
|
<div className="flex flex-col gap-3">
|
||||||
title="Internet path map"
|
|
||||||
description="Основной и текущий путь трафика HomeRouter → Internet"
|
|
||||||
headerRight={
|
|
||||||
<StatusBadge
|
|
||||||
status={
|
|
||||||
model?.pathState === "healthy"
|
|
||||||
? "online"
|
|
||||||
: model?.pathState === "failover"
|
|
||||||
? "degraded"
|
|
||||||
: "offline"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
contentClassName="px-5 pb-4"
|
|
||||||
>
|
|
||||||
{!model && (
|
{!model && (
|
||||||
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
|
<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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, type ReactNode } from "react"
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import type { Backup } from "@/lib/data"
|
import type { Backup } from "@/lib/data"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
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 { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
import {
|
import {
|
||||||
DATA_GRID_CELL_PAD,
|
DATA_GRID_CELL_PAD,
|
||||||
@@ -18,13 +19,28 @@ import {
|
|||||||
} from "@/components/data-grids/shared/data-grid-layout"
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
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 {
|
interface BackupsDataGridProps {
|
||||||
backups: Backup[]
|
backups: Backup[]
|
||||||
onDownload: (id: string, filename: string) => void
|
onDownload: (id: string, filename: string) => void
|
||||||
onRestore: (backup: Backup) => 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({
|
function BackupsDataGrid({
|
||||||
@@ -32,6 +48,9 @@ function BackupsDataGrid({
|
|||||||
onDownload,
|
onDownload,
|
||||||
onRestore,
|
onRestore,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
emptyAction,
|
||||||
|
emptyTitle = "Нет бэкапов",
|
||||||
|
emptyDescription = "Создайте первый бэкап вручную или настройте расписание",
|
||||||
}: BackupsDataGridProps) {
|
}: BackupsDataGridProps) {
|
||||||
const columns = useMemo<ColumnDef<Backup>[]>(
|
const columns = useMemo<ColumnDef<Backup>[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -39,9 +58,27 @@ function BackupsDataGrid({
|
|||||||
id: "filename",
|
id: "filename",
|
||||||
accessorKey: "filename",
|
accessorKey: "filename",
|
||||||
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
|
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
|
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: {
|
meta: {
|
||||||
headerTitle: "Файл",
|
headerTitle: "Файл",
|
||||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
@@ -78,23 +115,35 @@ function BackupsDataGrid({
|
|||||||
id: "kind",
|
id: "kind",
|
||||||
accessorKey: "kind",
|
accessorKey: "kind",
|
||||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
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 }) => {
|
cell: ({ row }) => {
|
||||||
const kind = row.original.kind
|
const badge = storageBadge(row.original.storage)
|
||||||
return (
|
return (
|
||||||
<span
|
<Badge variant={badge.variant} size="sm" radius="full">
|
||||||
className={cn(
|
{badge.label}
|
||||||
"text-xs px-2 py-0.5 rounded border font-medium",
|
</Badge>
|
||||||
kind === "manual"
|
|
||||||
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
|
||||||
: "bg-muted text-muted-foreground border-border",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{kind === "auto" ? "авто" : "вручную"}
|
|
||||||
</span>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
headerTitle: "Тип",
|
headerTitle: "Хранилище",
|
||||||
headerClassName: DATA_GRID_CELL_PAD,
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
cellClassName: DATA_GRID_CELL_PAD,
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
},
|
},
|
||||||
@@ -135,29 +184,35 @@ function BackupsDataGrid({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7"
|
className="size-7"
|
||||||
title="Скачать"
|
title="Скачать"
|
||||||
|
aria-label={`Скачать ${b.filename}`}
|
||||||
onClick={() => onDownload(b.id, b.filename)}
|
onClick={() => onDownload(b.id, b.filename)}
|
||||||
>
|
>
|
||||||
<DownloadIcon className="size-3.5" />
|
<DownloadIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7"
|
className="size-7"
|
||||||
title="Восстановить"
|
title="Восстановить"
|
||||||
|
aria-label={`Восстановить ${b.filename}`}
|
||||||
onClick={() => onRestore(b)}
|
onClick={() => onRestore(b)}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-3.5" />
|
<RefreshCwIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 text-destructive hover:text-destructive"
|
className="size-7 text-destructive hover:text-destructive"
|
||||||
title="Удалить"
|
title="Удалить"
|
||||||
onClick={() => onDelete(b.id)}
|
aria-label={`Удалить ${b.filename}`}
|
||||||
|
onClick={() => onDelete(b)}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="size-3.5" />
|
<Trash2Icon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -186,9 +241,10 @@ function BackupsDataGrid({
|
|||||||
if (backups.length === 0) {
|
if (backups.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<HardDriveIcon className="size-4" />}
|
icon={<HardDriveIcon className="size-5" />}
|
||||||
title="Нет бэкапов"
|
title={emptyTitle}
|
||||||
description="Создайте первый бэкап вручную или настройте расписание"
|
description={emptyDescription}
|
||||||
|
action={emptyAction}
|
||||||
className="border-0 py-10"
|
className="border-0 py-10"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ReactNode } from "react"
|
import { ReactNode } from "react"
|
||||||
import { SearchIcon } from "lucide-react"
|
import { ListFilterIcon, SearchIcon } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputGroupAddon,
|
InputGroupAddon,
|
||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
} from "@/components/ui/input-group"
|
} from "@/components/ui/input-group"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Filters,
|
Filters,
|
||||||
type Filter,
|
type Filter,
|
||||||
@@ -77,6 +78,12 @@ function DataPageToolbar<T extends string = string>({
|
|||||||
fields={filterFields}
|
fields={filterFields}
|
||||||
onChange={onFiltersChange}
|
onChange={onFiltersChange}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" size="sm">
|
||||||
|
<ListFilterIcon className="size-3.5" />
|
||||||
|
Фильтры
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onSearchChange != null && (
|
{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)" />
|
<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>
|
</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:
|
default:
|
||||||
return <GenericCloud size={size} />
|
return <GenericCloud size={size} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,3 +3,7 @@ export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet
|
|||||||
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
|
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
|
||||||
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
|
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
|
||||||
export { kpiCols } from "./kpi-cols"
|
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -70,63 +70,72 @@ export function TrafficRxTxChart({
|
|||||||
rx,
|
rx,
|
||||||
tx,
|
tx,
|
||||||
range = "1h",
|
range = "1h",
|
||||||
|
embedded = false,
|
||||||
}: {
|
}: {
|
||||||
rx: number[]
|
rx: number[]
|
||||||
tx: number[]
|
tx: number[]
|
||||||
range?: string
|
range?: string
|
||||||
|
/** Skip outer Frame when already inside OpsPanel / Frame. */
|
||||||
|
embedded?: boolean
|
||||||
}) {
|
}) {
|
||||||
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
|
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
|
||||||
const data = toChartData(rx, tx, rangeMinutes)
|
const data = toChartData(rx, tx, rangeMinutes)
|
||||||
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
|
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 (
|
return (
|
||||||
<Frame className="w-full">
|
<Frame className="w-full">
|
||||||
<FramePanel className="flex flex-col gap-6">
|
<FramePanel className="flex flex-col gap-6">{chart}</FramePanel>
|
||||||
<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>
|
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -189,11 +189,14 @@ export interface FirewallAddressListEntry {
|
|||||||
export interface Backup {
|
export interface Backup {
|
||||||
id: string
|
id: string
|
||||||
server: string
|
server: string
|
||||||
|
serverId?: string | null
|
||||||
filename: string
|
filename: string
|
||||||
size: string
|
size: string
|
||||||
created: string
|
created: string
|
||||||
kind: "auto" | "manual"
|
kind: "auto" | "manual"
|
||||||
notes: string
|
notes: string
|
||||||
|
storage: "local" | "s3" | "both"
|
||||||
|
uploadError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── VXLAN ───────────────────────────────────────────────────────────────────
|
// ─── VXLAN ───────────────────────────────────────────────────────────────────
|
||||||
@@ -467,11 +470,11 @@ export const firewallRules: FirewallRule[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const backups: Backup[] = [
|
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: "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: "" },
|
{ 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: "" },
|
{ 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" },
|
{ 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: "" },
|
{ 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[] = [
|
export const pingProbes: PingProbe[] = [
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
|
|||||||
gre_bgp:
|
gre_bgp:
|
||||||
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
|
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
|
||||||
certificates_renew:
|
certificates_renew:
|
||||||
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
|
"Автообновление сертификатов, выпущенных через UI (ACME DNS-01 / Cloudflare). Отключается на странице «Сертификаты», если ACME ведёт RouterOS.",
|
||||||
backups:
|
backups:
|
||||||
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
|
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
|
||||||
alert_engine:
|
alert_engine:
|
||||||
|
|||||||
Generated
+411
@@ -50,6 +50,7 @@
|
|||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.2.2",
|
"@fastify/jwt": "^10.2.2",
|
||||||
"@fastify/type-provider-zod": "^1.0.0",
|
"@fastify/type-provider-zod": "^1.0.0",
|
||||||
@@ -110,6 +111,314 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -3759,6 +4068,87 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -5565,6 +5955,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
@@ -14401,6 +14797,21 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.1"
|
"zod": "^4.4.1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
|
"version": "16.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
||||||
|
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { z } from "zod"
|
|||||||
|
|
||||||
export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"])
|
export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"])
|
||||||
export const backupFormatSchema = z.enum(["rsc", "backup"])
|
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({
|
export const backupScheduleSettingsDtoSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
@@ -32,3 +34,48 @@ export const putBackupScheduleSettingsSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type BackupScheduleSettingsDto = z.infer<typeof backupScheduleSettingsDtoSchema>
|
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>
|
||||||
|
|||||||
+41
-2
@@ -1,15 +1,22 @@
|
|||||||
import { requestJson } from "@/shared/api/http-client"
|
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 = {
|
export type BackupItem = {
|
||||||
id: string
|
id: string
|
||||||
serverId: string
|
serverId: number | string | null
|
||||||
serverName: string
|
serverName: string
|
||||||
filename: string
|
filename: string
|
||||||
sizeBytes: number
|
sizeBytes: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
kind: "manual" | "auto"
|
kind: "manual" | "auto"
|
||||||
notes?: string
|
notes?: string
|
||||||
|
storage?: "local" | "s3" | "both"
|
||||||
|
s3Key?: string | null
|
||||||
|
uploadError?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBackupResponse = {
|
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" })
|
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> {
|
export async function getBackupScheduleSettings(baseUrl: string): Promise<BackupScheduleSettingsDto> {
|
||||||
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule")
|
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule")
|
||||||
}
|
}
|
||||||
@@ -81,3 +94,29 @@ export async function putBackupScheduleSettings(
|
|||||||
body: JSON.stringify(payload),
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user