Compare commits

...
1 Commits
Author SHA1 Message Date
DenozordecandCursor cff26813b9 feat(backups): добавить S3-хранилище и обновить экран бэкапов
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 4m37s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 16s
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами.
Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:06:27 +07:00
21 changed files with 2429 additions and 637 deletions
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
-- S3-compatible storage for RouterOS backups
CREATE TABLE IF NOT EXISTS backup_storage_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
s3_endpoint TEXT NOT NULL DEFAULT '',
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
s3_bucket TEXT NOT NULL DEFAULT '',
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
s3_access_key_id TEXT NOT NULL DEFAULT '',
s3_secret_access_key TEXT NOT NULL DEFAULT '',
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
last_test_at TIMESTAMPTZ,
last_test_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO backup_storage_settings (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS s3_key TEXT;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
ALTER TABLE backup_entries
ADD COLUMN IF NOT EXISTS upload_error TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
) THEN
ALTER TABLE backup_entries
ADD CONSTRAINT backup_entries_storage_check
CHECK (storage IN ('local', 's3', 'both'));
END IF;
END $$;
+3 -1
View File
@@ -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:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg"
"test:backups": "tsx src/services/s3-backup-client.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
+20
View File
@@ -420,6 +420,22 @@ export const backupScheduleSettings = pgTable("backup_schedule_settings", {
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const backupStorageSettings = pgTable("backup_storage_settings", {
id: idSingleton(),
provider: text("provider", { enum: ["local", "s3"] }).notNull().default("local"),
s3Endpoint: text("s3_endpoint").notNull().default(""),
s3Region: text("s3_region").notNull().default("us-east-1"),
s3Bucket: text("s3_bucket").notNull().default(""),
s3Prefix: text("s3_prefix").notNull().default("mikrotik"),
s3AccessKeyId: text("s3_access_key_id").notNull().default(""),
s3SecretAccessKey: text("s3_secret_access_key").notNull().default(""),
s3ForcePathStyle: boolean("s3_force_path_style").notNull().default(true),
keepLocalCopy: boolean("keep_local_copy").notNull().default(true),
lastTestAt: ts("last_test_at"),
lastTestError: text("last_test_error"),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const backupEntries = pgTable("backup_entries", {
id: text("id").primaryKey(),
serverId: bigint("server_id", { mode: "number" })
@@ -429,6 +445,10 @@ export const backupEntries = pgTable("backup_entries", {
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
notes: text("notes"),
storage: text("storage", { enum: ["local", "s3", "both"] }).notNull().default("local"),
s3Key: text("s3_key"),
s3Etag: text("s3_etag"),
uploadError: text("upload_error"),
createdAt: ts("created_at").notNull(),
}, (t) => [
uniqueIndex("idx_backup_entries_filename").on(t.filename),
+6
View File
@@ -81,6 +81,12 @@ const TABLES: TableCopy[] = [
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
["last_error", "text"], ["updated_at", "ts"],
]},
{ table: "backup_storage_settings", upsert: true, columns: [
["id", "int"], ["provider", "text"], ["s3_endpoint", "text"], ["s3_region", "text"],
["s3_bucket", "text"], ["s3_prefix", "text"], ["s3_access_key_id", "text"],
["s3_secret_access_key", "text"], ["s3_force_path_style", "bool"], ["keep_local_copy", "bool"],
["last_test_at", "ts"], ["last_test_error", "text"], ["updated_at", "ts"],
]},
{ table: "internet_path_settings", upsert: true, columns: [
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
+71 -10
View File
@@ -1,20 +1,26 @@
import { randomUUID } from "node:crypto"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
import {
putBackupScheduleSettingsSchema,
putBackupStorageSettingsSchema,
} from "@mmapp/contracts/backups"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { appendEvent } from "../modules/events/service/events-service.js"
import { refreshScheduler } from "../services/scheduler.js"
import {
deleteBackupRecord,
getBackupById,
getBackupsDir,
getBackupScheduleSettings,
getBackupStorageSettings,
listBackups,
readBackupContent,
restoreBackupToDevice,
runBackupForServer,
syncBackupsFromS3,
testBackupStorageConnection,
updateBackupScheduleSettings,
updateBackupStorageSettings,
type BackupMeta,
} from "../services/backup-service.js"
@@ -97,6 +103,39 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send(result)
})
app.get("/backups/storage", async (_req, reply) => {
return reply.send(await getBackupStorageSettings())
})
app.put("/backups/storage", async (req, reply) => {
const parsed = putBackupStorageSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
return reply.send(await updateBackupStorageSettings(parsed.data))
})
app.post("/backups/storage/test", async (_req, reply) => {
try {
return reply.send(await testBackupStorageConnection())
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Ошибка проверки S3",
settings: await getBackupStorageSettings(),
})
}
})
app.post("/backups/storage/sync", async (_req, reply) => {
try {
return reply.send(await syncBackupsFromS3())
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Ошибка синхронизации S3",
})
}
})
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
const inputIds = req.body.serverIds.map((x) => String(x))
const notes = req.body.notes?.trim() || undefined
@@ -169,12 +208,34 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const hit = await getBackupById(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
const filePath = path.join(getBackupsDir(), hit.filename)
const content = await readFile(filePath, "utf8").catch(() => null)
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
reply.header("Content-Type", "text/plain; charset=utf-8")
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
return reply.send(content)
try {
const content = await readBackupContent(hit)
reply.header("Content-Type", "text/plain; charset=utf-8")
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
return reply.send(content)
} catch {
return reply.status(404).send({ error: "Файл бэкапа не найден" })
}
})
app.post("/backups/:id/restore", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
try {
const result = await restoreBackupToDevice(req.params.id)
await appendEvent({
level: "warning",
eventType: "backups.restore",
sourceModule: "backups",
title: "Восстановление бэкапа",
message: `${result.filename}${result.serverName}`,
entityType: "backup",
entityId: req.params.id,
})
return reply.send(result)
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Не удалось восстановить бэкап",
})
}
})
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
+301 -12
View File
@@ -1,14 +1,30 @@
import { randomUUID } from "node:crypto"
import { mkdir, rm, stat, writeFile } from "node:fs/promises"
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
import path from "node:path"
import { desc, eq } from "drizzle-orm"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import type {
BackupScheduleSettingsDto,
BackupStorageSettingsDto,
PutBackupStorageSettings,
} from "@mmapp/contracts/backups"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { MikrotikClient } from "./mikrotik.js"
import {
buildS3ObjectKey,
createS3ClientFromConfig,
parseS3ObjectKey,
sanitizeServerName,
s3DeleteObject,
s3GetObject,
s3ListObjects,
s3PutObject,
s3TestConnection,
type S3BackupConfig,
} from "./s3-backup-client.js"
const SETTINGS_ID = 1
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
@@ -22,9 +38,14 @@ export type BackupMeta = {
createdAt: string
kind: "manual" | "auto"
notes?: string
storage: "local" | "s3" | "both"
s3Key?: string | null
uploadError?: string | null
}
function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
type BackupRow = typeof backupEntries.$inferSelect
function rowToMeta(row: BackupRow): BackupMeta {
return {
id: row.id,
serverId: row.serverId,
@@ -34,6 +55,9 @@ function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
createdAt: row.createdAt,
kind: row.kind,
notes: row.notes ?? undefined,
storage: row.storage ?? "local",
s3Key: row.s3Key,
uploadError: row.uploadError,
}
}
@@ -60,16 +84,36 @@ export async function insertBackup(meta: BackupMeta): Promise<void> {
sizeBytes: meta.sizeBytes,
kind: meta.kind,
notes: meta.notes ?? null,
storage: meta.storage,
s3Key: meta.s3Key ?? null,
s3Etag: null,
uploadError: meta.uploadError ?? null,
createdAt: meta.createdAt,
})
}
async function getBackupRow(id: string): Promise<BackupRow | undefined> {
return (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
}
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
const hit = await getBackupById(id)
if (!hit) return null
const row = await getBackupRow(id)
if (!row) return null
const meta = rowToMeta(row)
if (row.s3Key) {
try {
const cfg = await getS3ConfigIfEnabled()
if (cfg) {
const client = createS3ClientFromConfig(cfg)
await s3DeleteObject(client, cfg.bucket, row.s3Key)
}
} catch {
/* объект мог уже отсутствовать */
}
}
await db.delete(backupEntries).where(eq(backupEntries.id, id))
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
return hit
await rm(path.join(BACKUPS_DIR, row.filename), { force: true })
return meta
}
function fmtTs(d = new Date()): string {
@@ -167,6 +211,127 @@ export async function touchBackupScheduleRunMeta(patch: {
}).where(eq(backupScheduleSettings.id, SETTINGS_ID))
}
function defaultStorageRow() {
return {
id: SETTINGS_ID,
provider: "local" as const,
s3Endpoint: "",
s3Region: "us-east-1",
s3Bucket: "",
s3Prefix: "mikrotik",
s3AccessKeyId: "",
s3SecretAccessKey: "",
s3ForcePathStyle: true,
keepLocalCopy: true,
lastTestAt: null as string | null,
lastTestError: null as string | null,
updatedAt: new Date().toISOString(),
}
}
async function getBackupStorageSettingsRow() {
return (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
?? defaultStorageRow()
}
function toStorageDto(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): BackupStorageSettingsDto {
return {
provider: row.provider,
s3Endpoint: row.s3Endpoint,
s3Region: row.s3Region,
s3Bucket: row.s3Bucket,
s3Prefix: row.s3Prefix,
s3AccessKeyId: row.s3AccessKeyId,
secretConfigured: Boolean(row.s3SecretAccessKey),
s3ForcePathStyle: row.s3ForcePathStyle,
keepLocalCopy: row.keepLocalCopy,
lastTestAt: row.lastTestAt ?? null,
lastTestError: row.lastTestError ?? null,
updatedAt: row.updatedAt,
}
}
export async function getBackupStorageSettings(): Promise<BackupStorageSettingsDto> {
return toStorageDto(await getBackupStorageSettingsRow())
}
export async function updateBackupStorageSettings(
patch: PutBackupStorageSettings,
): Promise<BackupStorageSettingsDto> {
const prev = await getBackupStorageSettingsRow()
const now = new Date().toISOString()
const secret = patch.s3SecretAccessKey
const next = {
provider: patch.provider ?? prev.provider,
s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint,
s3Region: patch.s3Region ?? prev.s3Region,
s3Bucket: patch.s3Bucket ?? prev.s3Bucket,
s3Prefix: patch.s3Prefix ?? prev.s3Prefix,
s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
s3SecretAccessKey: secret && secret.length > 0 ? secret : prev.s3SecretAccessKey,
s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle,
keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
updatedAt: now,
}
if ((await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]) {
await db.update(backupStorageSettings).set(next).where(eq(backupStorageSettings.id, SETTINGS_ID))
} else {
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...next })
}
return await getBackupStorageSettings()
}
function rowToS3Config(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): S3BackupConfig | null {
if (row.provider !== "s3") return null
if (!row.s3Bucket.trim() || !row.s3AccessKeyId.trim() || !row.s3SecretAccessKey) return null
return {
endpoint: row.s3Endpoint,
region: row.s3Region,
bucket: row.s3Bucket.trim(),
prefix: row.s3Prefix,
accessKeyId: row.s3AccessKeyId,
secretAccessKey: row.s3SecretAccessKey,
forcePathStyle: row.s3ForcePathStyle,
}
}
async function getS3ConfigIfEnabled(): Promise<S3BackupConfig | null> {
return rowToS3Config(await getBackupStorageSettingsRow())
}
export async function testBackupStorageConnection(): Promise<BackupStorageSettingsDto> {
const row = await getBackupStorageSettingsRow()
const cfg = rowToS3Config(row)
const now = new Date().toISOString()
if (!cfg) {
const error = row.provider === "s3"
? "Заполните bucket, ключ доступа и секрет"
: "S3 не выбран"
await persistStorageTest(now, error)
throw new Error(error)
}
try {
const client = createS3ClientFromConfig(cfg)
await s3TestConnection(client, cfg.bucket)
await persistStorageTest(now, null)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
await persistStorageTest(now, message)
throw new Error(message)
}
return await getBackupStorageSettings()
}
async function persistStorageTest(at: string, error: string | null) {
const exists = (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
const patch = { lastTestAt: at, lastTestError: error, updatedAt: at }
if (exists) {
await db.update(backupStorageSettings).set(patch).where(eq(backupStorageSettings.id, SETTINGS_ID))
} else {
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...patch })
}
}
export async function resolveBackupServerIds(settings: BackupScheduleSettingsDto): Promise<string[]> {
const enabled = new Set((await listServersRead()).map((s) => String(s.id)))
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
@@ -214,6 +379,23 @@ export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, last
return true
}
async function uploadBackupToS3(params: {
serverName: string
filename: string
body: string
}): Promise<{ key: string; etag?: string } | { error: string }> {
const cfg = await getS3ConfigIfEnabled()
if (!cfg) return { error: "S3 не настроен" }
try {
const client = createS3ClientFromConfig(cfg)
const key = buildS3ObjectKey(cfg.prefix, sanitizeServerName(params.serverName), params.filename)
const put = await s3PutObject(client, cfg.bucket, key, params.body)
return { key, etag: put.etag }
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) }
}
}
export async function runBackupForServer(
id: string,
kind: BackupMeta["kind"],
@@ -230,12 +412,33 @@ export async function runBackupForServer(
const client = MikrotikClient.fromServer(row)
const script = await client.exportConfigScript()
const ts = fmtTs()
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
const safeServer = sanitizeServerName(row.name)
const filename = `${safeServer}_${ts}.rsc`
const filePath = path.join(BACKUPS_DIR, filename)
await ensureBackupStorage()
await writeFile(filePath, script, "utf8")
const st = await stat(filePath)
const storageRow = await getBackupStorageSettingsRow()
let storage: BackupMeta["storage"] = "local"
let s3Key: string | null = null
let s3Etag: string | null = null
let uploadError: string | null = null
if (storageRow.provider === "s3") {
const uploaded = await uploadBackupToS3({ serverName: row.name, filename, body: script })
if ("key" in uploaded) {
s3Key = uploaded.key
s3Etag = uploaded.etag ?? null
storage = storageRow.keepLocalCopy ? "both" : "s3"
if (!storageRow.keepLocalCopy) {
await rm(filePath, { force: true })
}
} else {
uploadError = uploaded.error
storage = "local"
}
}
const meta: BackupMeta = {
id: randomUUID(),
serverId: row.id,
@@ -245,8 +448,24 @@ export async function runBackupForServer(
createdAt: new Date().toISOString(),
kind,
notes,
storage,
s3Key,
uploadError,
}
await insertBackup(meta)
await db.insert(backupEntries).values({
id: meta.id,
serverId: meta.serverId,
serverName: meta.serverName,
filename: meta.filename,
sizeBytes: meta.sizeBytes,
kind: meta.kind,
notes: meta.notes ?? null,
storage: meta.storage,
s3Key: meta.s3Key ?? null,
s3Etag,
uploadError: meta.uploadError ?? null,
createdAt: meta.createdAt,
})
return meta
}
@@ -257,12 +476,82 @@ export async function pruneBackupsForServer(serverId: string, keepCount: number)
if (rows.length <= keepCount) return 0
const toDelete = rows.slice(keepCount)
for (const hit of toDelete) {
await db.delete(backupEntries).where(eq(backupEntries.id, hit.id))
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
await deleteBackupRecord(hit.id)
}
return toDelete.length
}
export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) {
try {
const cfg = await getS3ConfigIfEnabled()
if (cfg) {
const client = createS3ClientFromConfig(cfg)
return await s3GetObject(client, cfg.bucket, meta.s3Key)
}
} catch {
/* fallback: локальная копия, если есть */
}
}
return await readFile(path.join(BACKUPS_DIR, meta.filename))
}
export async function restoreBackupToDevice(id: string): Promise<{ filename: string; serverName: string }> {
const meta = await getBackupById(id)
if (!meta) throw new Error("Бэкап не найден")
if (!meta.serverId) throw new Error("Сервер бэкапа удалён — восстановить нельзя")
const row = await getServerRowById(meta.serverId)
if (!row) throw new Error("Сервер не найден")
const content = await readBackupContent(meta)
const client = MikrotikClient.fromServer(row)
const uploaded = await client.uploadTextFile(meta.filename, content.toString("utf8"), 60_000)
await client.importUploadedFile(uploaded)
return { filename: meta.filename, serverName: row.name }
}
export async function syncBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
const cfg = await getS3ConfigIfEnabled()
if (!cfg) throw new Error("S3 не настроен")
const client = createS3ClientFromConfig(cfg)
const objects = await s3ListObjects(client, cfg.bucket, cfg.prefix)
const existing = new Set(
(await db.select({ filename: backupEntries.filename, s3Key: backupEntries.s3Key }).from(backupEntries))
.flatMap((row) => [row.filename, row.s3Key].filter((v): v is string => Boolean(v))),
)
let imported = 0
let skipped = 0
for (const obj of objects) {
if (!obj.key.toLowerCase().endsWith(".rsc")) {
skipped += 1
continue
}
const parsed = parseS3ObjectKey(obj.key)
if (existing.has(obj.key) || existing.has(parsed.filename)) {
skipped += 1
continue
}
const createdAt = obj.lastModified ?? new Date().toISOString()
await db.insert(backupEntries).values({
id: randomUUID(),
serverId: null,
serverName: parsed.serverName,
filename: parsed.filename,
sizeBytes: obj.size,
kind: "auto",
notes: "Импорт из S3",
storage: "s3",
s3Key: obj.key,
s3Etag: null,
uploadError: null,
createdAt,
})
existing.add(obj.key)
existing.add(parsed.filename)
imported += 1
}
return { imported, skipped }
}
export function getBackupsDir(): string {
return BACKUPS_DIR
}
+8
View File
@@ -586,6 +586,14 @@ export class MikrotikClient {
: new Error(`Не удалось загрузить файл ${normalized} на RouterOS`)
}
async importUploadedFile(fileName: string): Promise<unknown> {
try {
return await this.post("/import", { "file-name": fileName }, 120_000)
} catch {
return await this.post("/execute", { script: `/import file-name="${fileName}"` }, 120_000)
}
}
async importCertificate(params: {
fileName: string
name: string
@@ -0,0 +1,89 @@
import assert from "node:assert/strict"
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
ListObjectsV2Command,
PutObjectCommand,
type S3Client,
} from "@aws-sdk/client-s3"
import {
buildS3ObjectKey,
normalizeS3Prefix,
parseS3ObjectKey,
sanitizeServerName,
s3DeleteObject,
s3GetObject,
s3ListObjects,
s3PutObject,
s3TestConnection,
} from "./s3-backup-client.js"
assert.equal(normalizeS3Prefix("/mikrotik/backups/"), "mikrotik/backups")
assert.equal(sanitizeServerName("MSK CHR 01"), "MSK_CHR_01")
assert.equal(
buildS3ObjectKey("mikrotik", "msk-chr01", "chr_2026-09-08_03-00-00.rsc"),
"mikrotik/msk-chr01/chr_2026-09-08_03-00-00.rsc",
)
assert.deepEqual(
parseS3ObjectKey("mikrotik/msk-chr01/chr_2026-09-08_03-00-00.rsc"),
{ filename: "chr_2026-09-08_03-00-00.rsc", serverName: "msk-chr01" },
)
const store = new Map<string, Buffer>()
let lastCommand = ""
const fake = {
send: async (command: { input?: Record<string, unknown> }) => {
const name = command.constructor.name
lastCommand = name
const input = command.input ?? {}
if (command instanceof HeadBucketCommand || name === "HeadBucketCommand") {
if (input.Bucket !== "backups") throw new Error("no bucket")
return {}
}
if (command instanceof PutObjectCommand || name === "PutObjectCommand") {
const key = String(input.Key)
const body = input.Body
store.set(key, Buffer.isBuffer(body) ? body : Buffer.from(String(body)))
return { ETag: '"etag-1"' }
}
if (command instanceof GetObjectCommand || name === "GetObjectCommand") {
const key = String(input.Key)
const body = store.get(key)
if (!body) throw new Error("not found")
return { Body: { transformToByteArray: async () => new Uint8Array(body) } }
}
if (command instanceof DeleteObjectCommand || name === "DeleteObjectCommand") {
store.delete(String(input.Key))
return {}
}
if (command instanceof ListObjectsV2Command || name === "ListObjectsV2Command") {
const prefix = String(input.Prefix ?? "")
const contents = [...store.entries()]
.filter(([key]) => !prefix || key.startsWith(prefix))
.map(([key, buf]) => ({ Key: key, Size: buf.length, LastModified: new Date("2026-09-08T00:00:00Z") }))
return { Contents: contents, IsTruncated: false }
}
throw new Error(`unexpected command ${name}`)
},
} as unknown as S3Client
await s3TestConnection(fake, "backups")
assert.equal(lastCommand === "HeadBucketCommand" || lastCommand.includes("Head"), true)
const put = await s3PutObject(fake, "backups", "mikrotik/a/file.rsc", "hello")
assert.equal(put.etag, '"etag-1"')
const got = await s3GetObject(fake, "backups", "mikrotik/a/file.rsc")
assert.equal(got.toString("utf8"), "hello")
const listed = await s3ListObjects(fake, "backups", "mikrotik")
assert.equal(listed.length, 1)
assert.equal(listed[0]?.key, "mikrotik/a/file.rsc")
await s3DeleteObject(fake, "backups", "mikrotik/a/file.rsc")
const after = await s3ListObjects(fake, "backups", "mikrotik")
assert.equal(after.length, 0)
console.log("s3-backup-client.test.ts: ok")
+128
View File
@@ -0,0 +1,128 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
type S3ClientConfig,
} from "@aws-sdk/client-s3"
export type S3BackupConfig = {
endpoint: string
region: string
bucket: string
prefix: string
accessKeyId: string
secretAccessKey: string
forcePathStyle: boolean
}
export type S3ListedObject = {
key: string
size: number
lastModified?: string
}
export function normalizeS3Prefix(prefix: string): string {
return prefix.trim().replace(/^\/+|\/+$/g, "")
}
export function sanitizeServerName(name: string): string {
const safe = name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "")
return safe || "server"
}
export function buildS3ObjectKey(prefix: string, serverSafe: string, filename: string): string {
const parts = [normalizeS3Prefix(prefix), sanitizeServerName(serverSafe), filename]
.filter((part) => part.length > 0)
return parts.join("/")
}
export function parseS3ObjectKey(key: string): { filename: string; serverName: string } {
const parts = key.split("/").filter(Boolean)
const filename = parts.pop() ?? key
const folder = parts.pop() ?? ""
const base = filename.replace(/\.(rsc|backup)$/i, "")
const fromFilename = base.replace(/_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/, "")
return { filename, serverName: folder || fromFilename || filename }
}
export function createS3ClientFromConfig(cfg: S3BackupConfig): S3Client {
const options: S3ClientConfig = {
region: cfg.region.trim() || "us-east-1",
credentials: {
accessKeyId: cfg.accessKeyId,
secretAccessKey: cfg.secretAccessKey,
},
forcePathStyle: cfg.forcePathStyle,
}
const endpoint = cfg.endpoint.trim()
if (endpoint) options.endpoint = endpoint
return new S3Client(options)
}
export async function s3TestConnection(client: S3Client, bucket: string): Promise<void> {
try {
await client.send(new HeadBucketCommand({ Bucket: bucket }))
} catch {
await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 1 }))
}
}
export async function s3PutObject(
client: S3Client,
bucket: string,
key: string,
body: Buffer | string,
): Promise<{ etag?: string }> {
const out = await client.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: "text/plain; charset=utf-8",
}))
return { etag: out.ETag }
}
export async function s3GetObject(
client: S3Client,
bucket: string,
key: string,
): Promise<Buffer> {
const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
const bytes = await out.Body?.transformToByteArray()
if (!bytes) throw new Error("Пустой объект S3")
return Buffer.from(bytes)
}
export async function s3DeleteObject(client: S3Client, bucket: string, key: string): Promise<void> {
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }))
}
export async function s3ListObjects(
client: S3Client,
bucket: string,
prefix: string,
): Promise<S3ListedObject[]> {
const items: S3ListedObject[] = []
let token: string | undefined
const normalized = normalizeS3Prefix(prefix)
do {
const out = await client.send(new ListObjectsV2Command({
Bucket: bucket,
Prefix: normalized ? `${normalized}/` : undefined,
ContinuationToken: token,
}))
for (const obj of out.Contents ?? []) {
if (!obj.Key) continue
items.push({
key: obj.Key,
size: obj.Size ?? 0,
lastModified: obj.LastModified?.toISOString(),
})
}
token = out.IsTruncated ? out.NextContinuationToken : undefined
} while (token)
return items
}
@@ -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>
)
}
+199
View File
@@ -0,0 +1,199 @@
"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 { 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 (
<button
key={s.id}
type="button"
onClick={() => onToggle(s.id)}
className={cn(
"flex 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",
)}
>
<div
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border",
)}
>
{checked && (
<svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
<path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<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>
)}
</button>
)
})}
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<FormField label="Заметка">
<Input
placeholder="Например: перед обновлением BGP"
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
/>
</FormField>
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">
Будет создан бэкап для <strong className="text-foreground">{selected.size}</strong> серверов.
</p>
<p className="text-muted-foreground">
Куда сохранится: <strong className="text-foreground">{destinationLabel}</strong>
</p>
{notes ? <p className="text-muted-foreground">Заметка: {notes}</p> : null}
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
{step > 1 && (
<Button type="button" variant="outline" className="flex-1" onClick={() => onStepChange(step - 1)}>
Назад
</Button>
)}
{step < 3 ? (
<Button
type="button"
className="flex-1"
disabled={step === 1 && selected.size === 0}
onClick={() => onStepChange(step + 1)}
>
Далее
</Button>
) : (
<Button
type="button"
className="flex-1"
disabled={selected.size === 0 || busy}
onClick={onSubmit}
>
Снять бэкап ({selected.size})
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+97
View File
@@ -0,0 +1,97 @@
"use client"
import type { Filter } from "@/components/reui/filters"
import type { Backup } from "@/lib/data"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import {
BACKUP_FILTER_ACCESSORS,
BACKUP_FILTER_FIELDS,
} from "@/lib/data-filters/backup-filter-fields"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
import { Button } from "@/components/ui/button"
import { PlusIcon } from "lucide-react"
type KindFilter = "all" | "auto" | "manual"
export function BackupsHistory({
backups,
kindFilter,
onKindFilterChange,
search,
onSearchChange,
filters,
onFiltersChange,
onDownload,
onRestore,
onDelete,
onCreate,
}: {
backups: Backup[]
kindFilter: KindFilter
onKindFilterChange: (value: KindFilter) => void
search: string
onSearchChange: (value: string) => void
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onDownload: (id: string, filename: string) => void
onRestore: (backup: Backup) => void
onDelete: (backup: Backup) => void
onCreate: () => void
}) {
const autoCount = backups.filter((b) => b.kind === "auto").length
const manualCount = backups.filter((b) => b.kind === "manual").length
const byKind = kindFilter === "all" ? backups : backups.filter((b) => b.kind === kindFilter)
const q = search.trim().toLowerCase()
const searched = q
? byKind.filter((b) =>
[b.filename, b.server, b.notes].some((v) => v.toLowerCase().includes(q)),
)
: byKind
const filtered = applyReuiFilters(searched, filters, BACKUP_FILTER_ACCESSORS)
const isEmptyAll = backups.length === 0
return (
<DataPageCard>
<DataPageToolbar
segmented={{
value: kindFilter,
onChange: onKindFilterChange,
options: [
{ value: "all", label: "Все", count: backups.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
],
}}
filters={filters}
onFiltersChange={onFiltersChange}
filterFields={BACKUP_FILTER_FIELDS}
search={search}
onSearchChange={onSearchChange}
searchPlaceholder="Поиск по файлу, серверу, заметке…"
countLabel={`${filtered.length} бэкапов`}
/>
<BackupsDataGrid
backups={filtered}
onDownload={onDownload}
onRestore={onRestore}
onDelete={onDelete}
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
emptyDescription={
isEmptyAll
? "Создайте первый бэкап вручную или настройте расписание"
: "Измените фильтры или поисковый запрос"
}
emptyAction={
isEmptyAll ? (
<Button type="button" size="sm" onClick={onCreate}>
<PlusIcon className="size-4" />
Новый бэкап
</Button>
) : null
}
/>
</DataPageCard>
)
}
+427
View File
@@ -0,0 +1,427 @@
"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 { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { LoaderCircleIcon } from "lucide-react"
export const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
export type BackupFreq = "daily" | "weekly" | "monthly"
export type StorageProvider = "local" | "s3"
export type BackupScheduleForm = {
enabled: boolean
frequency: BackupFreq
hour: number
minute: number
weekDay: number
monthDay: number
keepCount: number
format: "rsc" | "backup"
}
export type BackupStorageForm = {
provider: StorageProvider
s3Endpoint: string
s3Region: string
s3Bucket: string
s3Prefix: string
s3AccessKeyId: string
s3SecretAccessKey: string
s3ForcePathStyle: boolean
keepLocalCopy: boolean
showPassword: boolean
}
export const defaultSchedule: BackupScheduleForm = {
enabled: true,
frequency: "daily",
hour: 3,
minute: 0,
weekDay: 0,
monthDay: 1,
keepCount: 7,
format: "rsc",
}
export const defaultStorageForm: BackupStorageForm = {
provider: "local",
s3Endpoint: "",
s3Region: "us-east-1",
s3Bucket: "",
s3Prefix: "mikrotik",
s3AccessKeyId: "",
s3SecretAccessKey: "",
s3ForcePathStyle: true,
keepLocalCopy: true,
showPassword: false,
}
function storageStatus(saved: BackupStorageSettingsDto | null, form: BackupStorageForm) {
if (form.provider === "local") {
return { label: "Локально", variant: "secondary" as const }
}
if (saved?.lastTestError) {
return { label: "Ошибка", variant: "destructive-light" as const }
}
if (saved?.lastTestAt && !saved.lastTestError) {
return { label: "Connected", variant: "success-light" as const }
}
if (saved?.secretConfigured && saved.s3Bucket) {
return { label: "Не проверено", variant: "warning-light" as const }
}
return { label: "Не настроено", variant: "secondary" as const }
}
export function BackupsSettings({
schedule,
onScheduleChange,
storage,
onStorageChange,
savedStorage,
servers,
selectedServers,
onToggleServer,
onSelectAll,
onClearServers,
onSave,
onTest,
onSync,
saveBusy,
testBusy,
syncBusy,
}: {
schedule: BackupScheduleForm
onScheduleChange: <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => void
storage: BackupStorageForm
onStorageChange: <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => void
savedStorage: BackupStorageSettingsDto | null
servers: Server[]
selectedServers: Set<string>
onToggleServer: (id: string) => void
onSelectAll: () => void
onClearServers: () => void
onSave: () => void
onTest: () => void
onSync: () => void
saveBusy: boolean
testBusy: boolean
syncBusy: boolean
}) {
const status = storageStatus(savedStorage, storage)
const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••"
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<OpsPanel title="Расписание" description="Автоматический съём конфигурации" contentClassName="px-5 py-5 flex flex-col gap-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Автоматический бэкап</p>
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
</div>
<FormToggle checked={schedule.enabled} onChange={(v) => onScheduleChange("enabled", v)} />
</div>
<div className={cn("flex flex-col gap-4", !schedule.enabled && "opacity-40 pointer-events-none")}>
<FormField label="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => onScheduleChange("frequency", v)}
options={[
{ value: "daily", label: "Ежедневно" },
{ value: "weekly", label: "Еженедельно" },
{ value: "monthly", label: "Ежемесячно" },
]}
/>
</FormField>
{schedule.frequency === "weekly" && (
<FormField label="День недели">
<div className="flex gap-1">
{WEEK_DAYS.map((d, i) => (
<button
key={d}
type="button"
onClick={() => onScheduleChange("weekDay", i)}
className={cn(
"w-9 h-9 rounded text-sm font-medium border transition-colors",
schedule.weekDay === i
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{d}
</button>
))}
</div>
</FormField>
)}
{schedule.frequency === "monthly" && (
<FormField label="День месяца" hint="128">
<Input
type="number"
min={1}
max={28}
className="font-mono w-24"
value={schedule.monthDay}
onChange={(e) => onScheduleChange("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))}
/>
</FormField>
)}
<FormField label="Время запуска">
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={23}
className="font-mono w-20 text-center"
value={String(schedule.hour).padStart(2, "0")}
onChange={(e) => onScheduleChange("hour", Math.min(23, Math.max(0, Number(e.target.value))))}
/>
<span className="text-muted-foreground font-mono text-lg">:</span>
<div className="flex gap-1">
{[0, 15, 30, 45].map((m) => (
<button
key={m}
type="button"
onClick={() => onScheduleChange("minute", m)}
className={cn(
"px-2.5 py-1.5 rounded text-xs font-mono border transition-colors",
schedule.minute === m
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{String(m).padStart(2, "0")}
</button>
))}
</div>
</div>
</FormField>
<FormField label="Хранить бэкапов" hint="На каждый сервер">
<Input
type="number"
min={1}
max={90}
className="font-mono w-24"
value={schedule.keepCount}
onChange={(e) => onScheduleChange("keepCount", Math.max(1, Number(e.target.value)))}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-3">
<div>
<p className="text-sm font-medium">Формат файла</p>
<p className="text-xs text-muted-foreground mt-0.5">Снимается текстовый экспорт RouterOS</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" size="sm">.rsc</Badge>
<Badge variant="warning-light" size="sm">.backup скоро</Badge>
</div>
</div>
</div>
</OpsPanel>
<OpsPanel
title="Хранилище"
description="Локальный диск приложения или S3-compatible бакет"
headerRight={
<Badge variant={status.variant} size="sm" radius="full">
{status.label}
</Badge>
}
contentClassName="px-5 py-5 flex flex-col gap-5"
>
<FormField label="Тип хранилища">
<SegmentedControl
value={storage.provider}
onChange={(v) => onStorageChange("provider", v)}
options={[
{ value: "local", label: "Локально" },
{ value: "s3", label: "S3" },
]}
/>
</FormField>
{storage.provider === "local" ? (
<p className="text-xs text-muted-foreground">
Файлы пишутся в каталог приложения <span className="font-mono text-foreground">storage/backups</span>.
</p>
) : (
<div className="flex flex-col gap-4">
<FormField label="Endpoint" hint="Пусто для AWS. Для R2/MinIO/Selectel — полный URL">
<Input
className="font-mono"
placeholder="https://s3.amazonaws.com"
value={storage.s3Endpoint}
onChange={(e) => onStorageChange("s3Endpoint", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Region">
<Input
className="font-mono"
placeholder="us-east-1"
value={storage.s3Region}
onChange={(e) => onStorageChange("s3Region", e.target.value)}
/>
</FormField>
<FormField label="Bucket" required>
<Input
className="font-mono"
placeholder="mikrotik-backups"
value={storage.s3Bucket}
onChange={(e) => onStorageChange("s3Bucket", e.target.value)}
/>
</FormField>
</div>
<FormField label="Prefix">
<Input
className="font-mono"
placeholder="mikrotik"
value={storage.s3Prefix}
onChange={(e) => onStorageChange("s3Prefix", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Access key" required>
<Input
className="font-mono"
autoComplete="off"
value={storage.s3AccessKeyId}
onChange={(e) => onStorageChange("s3AccessKeyId", e.target.value)}
/>
</FormField>
<FormField label="Secret key">
<div className="relative">
<Input
type={storage.showPassword ? "text" : "password"}
className="font-mono pr-14"
autoComplete="new-password"
placeholder={secretPlaceholder}
value={storage.s3SecretAccessKey}
onChange={(e) => onStorageChange("s3SecretAccessKey", e.target.value)}
/>
<button
type="button"
onClick={() => onStorageChange("showPassword", !storage.showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-muted-foreground hover:text-foreground"
>
{storage.showPassword ? "скрыть" : "показ"}
</button>
</div>
</FormField>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Path-style</p>
<p className="text-xs text-muted-foreground mt-0.5">Нужен для MinIO и части совместимых API</p>
</div>
<FormToggle checked={storage.s3ForcePathStyle} onChange={(v) => onStorageChange("s3ForcePathStyle", v)} />
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Оставлять локальную копию</p>
<p className="text-xs text-muted-foreground mt-0.5">После успешной загрузки в S3</p>
</div>
<FormToggle checked={storage.keepLocalCopy} onChange={(v) => onStorageChange("keepLocalCopy", v)} />
</div>
{savedStorage?.lastTestError ? (
<p className="text-xs text-destructive">{savedStorage.lastTestError}</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onTest} disabled={testBusy}>
{testBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
Проверить
</Button>
<Button type="button" variant="outline" size="sm" onClick={onSync} disabled={syncBusy}>
{syncBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
Синхронизировать из бакета
</Button>
</div>
</div>
)}
</OpsPanel>
<OpsPanel
className="lg:col-span-2"
title="Серверы для бэкапа"
headerRight={
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
Выбрать все
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={onClearServers}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Сбросить
</button>
</div>
}
contentClassName="px-5 py-5 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{servers.map((s) => {
const checked = selectedServers.has(s.id)
return (
<button
key={s.id}
type="button"
onClick={() => onToggleServer(s.id)}
className={cn(
"flex 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",
)}
>
<div
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border",
)}
>
{checked && (
<svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
<path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<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>
</button>
)
})}
</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>
)
}
+79 -23
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo } from "react"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
@@ -8,8 +8,9 @@ import {
useReactTable,
} from "@tanstack/react-table"
import type { Backup } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
@@ -18,13 +19,28 @@ import {
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
import {
CloudIcon,
DownloadIcon,
HardDriveIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-react"
interface BackupsDataGridProps {
backups: Backup[]
onDownload: (id: string, filename: string) => void
onRestore: (backup: Backup) => void
onDelete: (id: string) => void
onDelete: (backup: Backup) => void
emptyAction?: ReactNode
emptyTitle?: string
emptyDescription?: string
}
function storageBadge(storage: Backup["storage"]) {
if (storage === "s3") return { label: "S3", variant: "info-light" as const }
if (storage === "both") return { label: "Локально + S3", variant: "success-light" as const }
return { label: "Локально", variant: "secondary" as const }
}
function BackupsDataGrid({
@@ -32,6 +48,9 @@ function BackupsDataGrid({
onDownload,
onRestore,
onDelete,
emptyAction,
emptyTitle = "Нет бэкапов",
emptyDescription = "Создайте первый бэкап вручную или настройте расписание",
}: BackupsDataGridProps) {
const columns = useMemo<ColumnDef<Backup>[]>(
() => [
@@ -39,9 +58,27 @@ function BackupsDataGrid({
id: "filename",
accessorKey: "filename",
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
),
cell: ({ row }) => {
const b = row.original
const inS3 = b.storage === "s3" || b.storage === "both"
return (
<div className="flex items-center gap-3 min-w-0">
<IconTile
variant="elevated"
className={inS3 ? "size-10.5 shrink-0 text-info" : "size-10.5 shrink-0 text-muted-foreground"}
aria-hidden="true"
>
{inS3 ? <CloudIcon /> : <HardDriveIcon />}
</IconTile>
<div className="min-w-0">
<span className="font-mono text-xs font-medium block truncate">{b.filename}</span>
{b.uploadError ? (
<span className="text-[11px] text-destructive truncate block">{b.uploadError}</span>
) : null}
</div>
</div>
)
},
meta: {
headerTitle: "Файл",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
@@ -78,23 +115,35 @@ function BackupsDataGrid({
id: "kind",
accessorKey: "kind",
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => (
<Badge
variant={row.original.kind === "manual" ? "info-light" : "secondary"}
size="sm"
radius="full"
>
{row.original.kind === "auto" ? "авто" : "вручную"}
</Badge>
),
meta: {
headerTitle: "Тип",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "storage",
accessorKey: "storage",
header: ({ column }) => <DataGridSortHeader column={column} title="Хранилище" />,
cell: ({ row }) => {
const kind = row.original.kind
const badge = storageBadge(row.original.storage)
return (
<span
className={cn(
"text-xs px-2 py-0.5 rounded border font-medium",
kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border",
)}
>
{kind === "auto" ? "авто" : "вручную"}
</span>
<Badge variant={badge.variant} size="sm" radius="full">
{badge.label}
</Badge>
)
},
meta: {
headerTitle: "Тип",
headerTitle: "Хранилище",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
@@ -135,29 +184,35 @@ function BackupsDataGrid({
return (
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
title="Скачать"
aria-label={`Скачать ${b.filename}`}
onClick={() => onDownload(b.id, b.filename)}
>
<DownloadIcon className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
title="Восстановить"
aria-label={`Восстановить ${b.filename}`}
onClick={() => onRestore(b)}
>
<RefreshCwIcon className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
title="Удалить"
onClick={() => onDelete(b.id)}
aria-label={`Удалить ${b.filename}`}
onClick={() => onDelete(b)}
>
<Trash2Icon className="size-3.5" />
</Button>
@@ -186,9 +241,10 @@ function BackupsDataGrid({
if (backups.length === 0) {
return (
<EmptyState
icon={<HardDriveIcon className="size-4" />}
title="Нет бэкапов"
description="Создайте первый бэкап вручную или настройте расписание"
icon={<HardDriveIcon className="size-5" />}
title={emptyTitle}
description={emptyDescription}
action={emptyAction}
className="border-0 py-10"
/>
)
+8 -1
View File
@@ -1,13 +1,14 @@
"use client"
import { ReactNode } from "react"
import { SearchIcon } from "lucide-react"
import { ListFilterIcon, SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { Button } from "@/components/ui/button"
import {
Filters,
type Filter,
@@ -77,6 +78,12 @@ function DataPageToolbar<T extends string = string>({
fields={filterFields}
onChange={onFiltersChange}
size="sm"
trigger={
<Button type="button" variant="outline" size="sm">
<ListFilterIcon className="size-3.5" />
Фильтры
</Button>
}
/>
)}
{onSearchChange != null && (
+36
View File
@@ -0,0 +1,36 @@
import type { FilterFieldConfig } from "@/components/reui/filters"
import type { Backup } from "@/lib/data"
export const BACKUP_FILTER_FIELDS: FilterFieldConfig[] = [
{
key: "server",
label: "Сервер",
type: "text",
placeholder: "Имя сервера",
},
{
key: "kind",
label: "Тип",
type: "multiselect",
options: [
{ value: "auto", label: "авто" },
{ value: "manual", label: "вручную" },
],
},
{
key: "storage",
label: "Хранилище",
type: "multiselect",
options: [
{ value: "local", label: "Локально" },
{ value: "s3", label: "S3" },
{ value: "both", label: "Локально + S3" },
],
},
]
export const BACKUP_FILTER_ACCESSORS = {
server: (b: Backup) => b.server,
kind: (b: Backup) => b.kind,
storage: (b: Backup) => b.storage,
}
+8 -5
View File
@@ -189,11 +189,14 @@ export interface FirewallAddressListEntry {
export interface Backup {
id: string
server: string
serverId?: string | null
filename: string
size: string
created: string
kind: "auto" | "manual"
notes: string
storage: "local" | "s3" | "both"
uploadError?: string
}
// ─── VXLAN ───────────────────────────────────────────────────────────────────
@@ -467,11 +470,11 @@ export const firewallRules: FirewallRule[] = [
]
export const backups: Backup[] = [
{ id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением" },
{ id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" },
{ id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "" },
{ id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP" },
{ id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" },
{ id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением", storage: "local" },
{ id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" },
{ id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "", storage: "s3" },
{ id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP", storage: "both" },
{ id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" },
]
export const pingProbes: PingProbe[] = [
+411
View File
@@ -50,6 +50,7 @@
"name": "mikrotik-manager-backend",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
@@ -110,6 +111,314 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.29",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz",
"integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1127.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1127.0.tgz",
"integrity": "sha512-0ZSAgmEda33xPqVPt+bx2KzrXV1cUCKRRVPGliLu+V7DzHPa04CqPSi1maGEM4O0LDP0iI6HRSW5ULloNwayNw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.29",
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/credential-provider-node": "^3.972.82",
"@aws-sdk/middleware-sdk-s3": "^3.972.75",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.977.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz",
"integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.5",
"@aws-sdk/xml-builder": "^3.972.40",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.33.3",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.17.2",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.70",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz",
"integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz",
"integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.15",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz",
"integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/credential-provider-env": "^3.972.70",
"@aws-sdk/credential-provider-http": "^3.972.72",
"@aws-sdk/credential-provider-login": "^3.972.77",
"@aws-sdk/credential-provider-process": "^3.972.70",
"@aws-sdk/credential-provider-sso": "^3.973.14",
"@aws-sdk/credential-provider-web-identity": "^3.972.76",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.77",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz",
"integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.82",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz",
"integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.70",
"@aws-sdk/credential-provider-http": "^3.972.72",
"@aws-sdk/credential-provider-ini": "^3.973.15",
"@aws-sdk/credential-provider-process": "^3.972.70",
"@aws-sdk/credential-provider-sso": "^3.973.14",
"@aws-sdk/credential-provider-web-identity": "^3.972.76",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.70",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz",
"integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.14",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz",
"integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/token-providers": "3.1116.0",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz",
"integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.75",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz",
"integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.44",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz",
"integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz",
"integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.5",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1116.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz",
"integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz",
"integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.40",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz",
"integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -3759,6 +4068,87 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@smithy/core": {
"version": "3.33.3",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz",
"integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz",
"integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz",
"integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -5565,6 +5955,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
@@ -14401,6 +14797,21 @@
"dependencies": {
"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"
}
}
}
}
+47
View File
@@ -2,6 +2,8 @@ import { z } from "zod"
export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"])
export const backupFormatSchema = z.enum(["rsc", "backup"])
export const backupStorageProviderSchema = z.enum(["local", "s3"])
export const backupObjectStorageSchema = z.enum(["local", "s3", "both"])
export const backupScheduleSettingsDtoSchema = z.object({
enabled: z.boolean(),
@@ -32,3 +34,48 @@ export const putBackupScheduleSettingsSchema = z.object({
})
export type BackupScheduleSettingsDto = z.infer<typeof backupScheduleSettingsDtoSchema>
export const backupStorageSettingsDtoSchema = z.object({
provider: backupStorageProviderSchema,
s3Endpoint: z.string(),
s3Region: z.string(),
s3Bucket: z.string(),
s3Prefix: z.string(),
s3AccessKeyId: z.string(),
secretConfigured: z.boolean(),
s3ForcePathStyle: z.boolean(),
keepLocalCopy: z.boolean(),
lastTestAt: z.string().nullable().optional(),
lastTestError: z.string().nullable().optional(),
updatedAt: z.string().optional(),
})
export const putBackupStorageSettingsSchema = z.object({
provider: backupStorageProviderSchema.optional(),
s3Endpoint: z.string().optional(),
s3Region: z.string().optional(),
s3Bucket: z.string().optional(),
s3Prefix: z.string().optional(),
s3AccessKeyId: z.string().optional(),
s3SecretAccessKey: z.string().optional(),
s3ForcePathStyle: z.boolean().optional(),
keepLocalCopy: z.boolean().optional(),
})
export const backupItemDtoSchema = z.object({
id: z.string(),
serverId: z.number().nullable(),
serverName: z.string(),
filename: z.string(),
sizeBytes: z.number(),
createdAt: z.string(),
kind: z.enum(["manual", "auto"]),
notes: z.string().optional(),
storage: backupObjectStorageSchema,
s3Key: z.string().nullable().optional(),
uploadError: z.string().nullable().optional(),
})
export type BackupStorageSettingsDto = z.infer<typeof backupStorageSettingsDtoSchema>
export type PutBackupStorageSettings = z.infer<typeof putBackupStorageSettingsSchema>
export type BackupItemDto = z.infer<typeof backupItemDtoSchema>
+41 -2
View File
@@ -1,15 +1,22 @@
import { requestJson } from "@/shared/api/http-client"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import type {
BackupScheduleSettingsDto,
BackupStorageSettingsDto,
PutBackupStorageSettings,
} from "@mmapp/contracts/backups"
export type BackupItem = {
id: string
serverId: string
serverId: number | string | null
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual" | "auto"
notes?: string
storage?: "local" | "s3" | "both"
s3Key?: string | null
uploadError?: string | null
}
type CreateBackupResponse = {
@@ -68,6 +75,12 @@ export async function deleteBackup(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/backups/${id}`, { method: "DELETE" })
}
export async function restoreBackup(baseUrl: string, id: string): Promise<{ filename: string; serverName: string }> {
return requestJson<{ filename: string; serverName: string }>(baseUrl, `/api/backups/${id}/restore`, {
method: "POST",
})
}
export async function getBackupScheduleSettings(baseUrl: string): Promise<BackupScheduleSettingsDto> {
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule")
}
@@ -81,3 +94,29 @@ export async function putBackupScheduleSettings(
body: JSON.stringify(payload),
})
}
export async function getBackupStorageSettings(baseUrl: string): Promise<BackupStorageSettingsDto> {
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage")
}
export async function putBackupStorageSettings(
baseUrl: string,
payload: PutBackupStorageSettings,
): Promise<BackupStorageSettingsDto> {
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage", {
method: "PUT",
body: JSON.stringify(payload),
})
}
export async function testBackupStorage(baseUrl: string): Promise<BackupStorageSettingsDto> {
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage/test", { method: "POST" })
}
export async function syncBackupsFromStorage(
baseUrl: string,
): Promise<{ imported: number; skipped: number }> {
return requestJson<{ imported: number; skipped: number }>(baseUrl, "/api/backups/storage/sync", {
method: "POST",
})
}