Compare commits

..
2 Commits
Author SHA1 Message Date
Denozordec 5f2b4e2d40 feat(backups): replace button with checkbox for server selection in backup settings
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m4s
Docker images / frontend-image (push) Successful in 3m10s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m29s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Updated the BackupCreateSheet and BackupsSettings components to use checkboxes for selecting servers instead of buttons. This improves user interaction by allowing for easier selection and toggling of server states. The Checkbox component is now integrated for better accessibility and functionality.
2026-09-08 14:47:44 +07:00
DenozordecandCursor cff26813b9 feat(backups): добавить S3-хранилище и обновить экран бэкапов
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 4m37s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 16s
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами.
Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:06:27 +07:00
22 changed files with 2414 additions and 638 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>
)
}
+191
View File
@@ -0,0 +1,191 @@
"use client"
import type { Server } from "@/lib/data"
import { FormField } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetFooter,
SheetClose,
} from "@/components/ui/sheet"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { cn } from "@/lib/utils"
export function BackupCreateSheet({
open,
onOpenChange,
step,
onStepChange,
servers,
selected,
onToggle,
onSelectAll,
onClear,
notes,
onNotesChange,
destinationLabel,
busy,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
step: number
onStepChange: (step: number) => void
servers: Server[]
selected: Set<string>
onToggle: (id: string) => void
onSelectAll: () => void
onClear: () => void
notes: string
onNotesChange: (value: string) => void
destinationLabel: string
busy: boolean
onSubmit: () => void
}) {
return (
<Sheet open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) onStepChange(1) }}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый бэкап</SheetTitle>
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
</SheetHeader>
<Stepper value={step} onValueChange={onStepChange} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Серверы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">Заметка</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">Подтверждение</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-3">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium">Выберите серверы</p>
<div className="flex items-center gap-2">
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
Все
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Сбросить
</button>
</div>
</div>
{servers.map((s) => {
const checked = selected.has(s.id)
return (
<label
key={s.id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40",
)}
>
<Checkbox
checked={checked}
onCheckedChange={() => onToggle(s.id)}
aria-label={`Выбрать ${s.name}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
<StatusBadge status={s.status} />
</div>
</div>
{s.status === "offline" && (
<span className="text-xs text-muted-foreground">недоступен</span>
)}
</label>
)
})}
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<FormField label="Заметка">
<Input
placeholder="Например: перед обновлением BGP"
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
/>
</FormField>
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">
Будет создан бэкап для <strong className="text-foreground">{selected.size}</strong> серверов.
</p>
<p className="text-muted-foreground">
Куда сохранится: <strong className="text-foreground">{destinationLabel}</strong>
</p>
{notes ? <p className="text-muted-foreground">Заметка: {notes}</p> : null}
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
{step > 1 && (
<Button type="button" variant="outline" className="flex-1" onClick={() => onStepChange(step - 1)}>
Назад
</Button>
)}
{step < 3 ? (
<Button
type="button"
className="flex-1"
disabled={step === 1 && selected.size === 0}
onClick={() => onStepChange(step + 1)}
>
Далее
</Button>
) : (
<Button
type="button"
className="flex-1"
disabled={selected.size === 0 || busy}
onClick={onSubmit}
>
Снять бэкап ({selected.size})
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+97
View File
@@ -0,0 +1,97 @@
"use client"
import type { Filter } from "@/components/reui/filters"
import type { Backup } from "@/lib/data"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import {
BACKUP_FILTER_ACCESSORS,
BACKUP_FILTER_FIELDS,
} from "@/lib/data-filters/backup-filter-fields"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
import { Button } from "@/components/ui/button"
import { PlusIcon } from "lucide-react"
type KindFilter = "all" | "auto" | "manual"
export function BackupsHistory({
backups,
kindFilter,
onKindFilterChange,
search,
onSearchChange,
filters,
onFiltersChange,
onDownload,
onRestore,
onDelete,
onCreate,
}: {
backups: Backup[]
kindFilter: KindFilter
onKindFilterChange: (value: KindFilter) => void
search: string
onSearchChange: (value: string) => void
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onDownload: (id: string, filename: string) => void
onRestore: (backup: Backup) => void
onDelete: (backup: Backup) => void
onCreate: () => void
}) {
const autoCount = backups.filter((b) => b.kind === "auto").length
const manualCount = backups.filter((b) => b.kind === "manual").length
const byKind = kindFilter === "all" ? backups : backups.filter((b) => b.kind === kindFilter)
const q = search.trim().toLowerCase()
const searched = q
? byKind.filter((b) =>
[b.filename, b.server, b.notes].some((v) => v.toLowerCase().includes(q)),
)
: byKind
const filtered = applyReuiFilters(searched, filters, BACKUP_FILTER_ACCESSORS)
const isEmptyAll = backups.length === 0
return (
<DataPageCard>
<DataPageToolbar
segmented={{
value: kindFilter,
onChange: onKindFilterChange,
options: [
{ value: "all", label: "Все", count: backups.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
],
}}
filters={filters}
onFiltersChange={onFiltersChange}
filterFields={BACKUP_FILTER_FIELDS}
search={search}
onSearchChange={onSearchChange}
searchPlaceholder="Поиск по файлу, серверу, заметке…"
countLabel={`${filtered.length} бэкапов`}
/>
<BackupsDataGrid
backups={filtered}
onDownload={onDownload}
onRestore={onRestore}
onDelete={onDelete}
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
emptyDescription={
isEmptyAll
? "Создайте первый бэкап вручную или настройте расписание"
: "Измените фильтры или поисковый запрос"
}
emptyAction={
isEmptyAll ? (
<Button type="button" size="sm" onClick={onCreate}>
<PlusIcon className="size-4" />
Новый бэкап
</Button>
) : null
}
/>
</DataPageCard>
)
}
+419
View File
@@ -0,0 +1,419 @@
"use client"
import type { Server } from "@/lib/data"
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups"
import { Badge } from "@/components/reui/badge"
import { OpsPanel } from "@/components/ops-panel"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { LoaderCircleIcon } from "lucide-react"
export const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
export type BackupFreq = "daily" | "weekly" | "monthly"
export type StorageProvider = "local" | "s3"
export type BackupScheduleForm = {
enabled: boolean
frequency: BackupFreq
hour: number
minute: number
weekDay: number
monthDay: number
keepCount: number
format: "rsc" | "backup"
}
export type BackupStorageForm = {
provider: StorageProvider
s3Endpoint: string
s3Region: string
s3Bucket: string
s3Prefix: string
s3AccessKeyId: string
s3SecretAccessKey: string
s3ForcePathStyle: boolean
keepLocalCopy: boolean
showPassword: boolean
}
export const defaultSchedule: BackupScheduleForm = {
enabled: true,
frequency: "daily",
hour: 3,
minute: 0,
weekDay: 0,
monthDay: 1,
keepCount: 7,
format: "rsc",
}
export const defaultStorageForm: BackupStorageForm = {
provider: "local",
s3Endpoint: "",
s3Region: "us-east-1",
s3Bucket: "",
s3Prefix: "mikrotik",
s3AccessKeyId: "",
s3SecretAccessKey: "",
s3ForcePathStyle: true,
keepLocalCopy: true,
showPassword: false,
}
function storageStatus(saved: BackupStorageSettingsDto | null, form: BackupStorageForm) {
if (form.provider === "local") {
return { label: "Локально", variant: "secondary" as const }
}
if (saved?.lastTestError) {
return { label: "Ошибка", variant: "destructive-light" as const }
}
if (saved?.lastTestAt && !saved.lastTestError) {
return { label: "Connected", variant: "success-light" as const }
}
if (saved?.secretConfigured && saved.s3Bucket) {
return { label: "Не проверено", variant: "warning-light" as const }
}
return { label: "Не настроено", variant: "secondary" as const }
}
export function BackupsSettings({
schedule,
onScheduleChange,
storage,
onStorageChange,
savedStorage,
servers,
selectedServers,
onToggleServer,
onSelectAll,
onClearServers,
onSave,
onTest,
onSync,
saveBusy,
testBusy,
syncBusy,
}: {
schedule: BackupScheduleForm
onScheduleChange: <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => void
storage: BackupStorageForm
onStorageChange: <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => void
savedStorage: BackupStorageSettingsDto | null
servers: Server[]
selectedServers: Set<string>
onToggleServer: (id: string) => void
onSelectAll: () => void
onClearServers: () => void
onSave: () => void
onTest: () => void
onSync: () => void
saveBusy: boolean
testBusy: boolean
syncBusy: boolean
}) {
const status = storageStatus(savedStorage, storage)
const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••"
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<OpsPanel title="Расписание" description="Автоматический съём конфигурации" contentClassName="px-5 py-5 flex flex-col gap-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Автоматический бэкап</p>
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
</div>
<FormToggle checked={schedule.enabled} onChange={(v) => onScheduleChange("enabled", v)} />
</div>
<div className={cn("flex flex-col gap-4", !schedule.enabled && "opacity-40 pointer-events-none")}>
<FormField label="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => onScheduleChange("frequency", v)}
options={[
{ value: "daily", label: "Ежедневно" },
{ value: "weekly", label: "Еженедельно" },
{ value: "monthly", label: "Ежемесячно" },
]}
/>
</FormField>
{schedule.frequency === "weekly" && (
<FormField label="День недели">
<div className="flex gap-1">
{WEEK_DAYS.map((d, i) => (
<button
key={d}
type="button"
onClick={() => onScheduleChange("weekDay", i)}
className={cn(
"w-9 h-9 rounded text-sm font-medium border transition-colors",
schedule.weekDay === i
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{d}
</button>
))}
</div>
</FormField>
)}
{schedule.frequency === "monthly" && (
<FormField label="День месяца" hint="128">
<Input
type="number"
min={1}
max={28}
className="font-mono w-24"
value={schedule.monthDay}
onChange={(e) => onScheduleChange("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))}
/>
</FormField>
)}
<FormField label="Время запуска">
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={23}
className="font-mono w-20 text-center"
value={String(schedule.hour).padStart(2, "0")}
onChange={(e) => onScheduleChange("hour", Math.min(23, Math.max(0, Number(e.target.value))))}
/>
<span className="text-muted-foreground font-mono text-lg">:</span>
<div className="flex gap-1">
{[0, 15, 30, 45].map((m) => (
<button
key={m}
type="button"
onClick={() => onScheduleChange("minute", m)}
className={cn(
"px-2.5 py-1.5 rounded text-xs font-mono border transition-colors",
schedule.minute === m
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{String(m).padStart(2, "0")}
</button>
))}
</div>
</div>
</FormField>
<FormField label="Хранить бэкапов" hint="На каждый сервер">
<Input
type="number"
min={1}
max={90}
className="font-mono w-24"
value={schedule.keepCount}
onChange={(e) => onScheduleChange("keepCount", Math.max(1, Number(e.target.value)))}
/>
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-3">
<div>
<p className="text-sm font-medium">Формат файла</p>
<p className="text-xs text-muted-foreground mt-0.5">Снимается текстовый экспорт RouterOS</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" size="sm">.rsc</Badge>
<Badge variant="warning-light" size="sm">.backup скоро</Badge>
</div>
</div>
</div>
</OpsPanel>
<OpsPanel
title="Хранилище"
description="Локальный диск приложения или S3-compatible бакет"
headerRight={
<Badge variant={status.variant} size="sm" radius="full">
{status.label}
</Badge>
}
contentClassName="px-5 py-5 flex flex-col gap-5"
>
<FormField label="Тип хранилища">
<SegmentedControl
value={storage.provider}
onChange={(v) => onStorageChange("provider", v)}
options={[
{ value: "local", label: "Локально" },
{ value: "s3", label: "S3" },
]}
/>
</FormField>
{storage.provider === "local" ? (
<p className="text-xs text-muted-foreground">
Файлы пишутся в каталог приложения <span className="font-mono text-foreground">storage/backups</span>.
</p>
) : (
<div className="flex flex-col gap-4">
<FormField label="Endpoint" hint="Пусто для AWS. Для R2/MinIO/Selectel — полный URL">
<Input
className="font-mono"
placeholder="https://s3.amazonaws.com"
value={storage.s3Endpoint}
onChange={(e) => onStorageChange("s3Endpoint", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Region">
<Input
className="font-mono"
placeholder="us-east-1"
value={storage.s3Region}
onChange={(e) => onStorageChange("s3Region", e.target.value)}
/>
</FormField>
<FormField label="Bucket" required>
<Input
className="font-mono"
placeholder="mikrotik-backups"
value={storage.s3Bucket}
onChange={(e) => onStorageChange("s3Bucket", e.target.value)}
/>
</FormField>
</div>
<FormField label="Prefix">
<Input
className="font-mono"
placeholder="mikrotik"
value={storage.s3Prefix}
onChange={(e) => onStorageChange("s3Prefix", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Access key" required>
<Input
className="font-mono"
autoComplete="off"
value={storage.s3AccessKeyId}
onChange={(e) => onStorageChange("s3AccessKeyId", e.target.value)}
/>
</FormField>
<FormField label="Secret key">
<div className="relative">
<Input
type={storage.showPassword ? "text" : "password"}
className="font-mono pr-14"
autoComplete="new-password"
placeholder={secretPlaceholder}
value={storage.s3SecretAccessKey}
onChange={(e) => onStorageChange("s3SecretAccessKey", e.target.value)}
/>
<button
type="button"
onClick={() => onStorageChange("showPassword", !storage.showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-muted-foreground hover:text-foreground"
>
{storage.showPassword ? "скрыть" : "показ"}
</button>
</div>
</FormField>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Path-style</p>
<p className="text-xs text-muted-foreground mt-0.5">Нужен для MinIO и части совместимых API</p>
</div>
<FormToggle checked={storage.s3ForcePathStyle} onChange={(v) => onStorageChange("s3ForcePathStyle", v)} />
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Оставлять локальную копию</p>
<p className="text-xs text-muted-foreground mt-0.5">После успешной загрузки в S3</p>
</div>
<FormToggle checked={storage.keepLocalCopy} onChange={(v) => onStorageChange("keepLocalCopy", v)} />
</div>
{savedStorage?.lastTestError ? (
<p className="text-xs text-destructive">{savedStorage.lastTestError}</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onTest} disabled={testBusy}>
{testBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
Проверить
</Button>
<Button type="button" variant="outline" size="sm" onClick={onSync} disabled={syncBusy}>
{syncBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
Синхронизировать из бакета
</Button>
</div>
</div>
)}
</OpsPanel>
<OpsPanel
className="lg:col-span-2"
title="Серверы для бэкапа"
headerRight={
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
Выбрать все
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={onClearServers}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Сбросить
</button>
</div>
}
contentClassName="px-5 py-5 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{servers.map((s) => {
const checked = selectedServers.has(s.id)
return (
<label
key={s.id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked
? "border-primary/40 bg-primary/5"
: "border-border hover:border-border/80 hover:bg-muted/40",
)}
>
<Checkbox
checked={checked}
onCheckedChange={() => onToggleServer(s.id)}
aria-label={`Выбрать ${s.name}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground">{s.site}</span>
<StatusBadge status={s.status} />
</div>
</div>
</label>
)
})}
</div>
<p className="text-xs text-muted-foreground">
Выбрано {selectedServers.size} из {servers.length} серверов
</p>
</OpsPanel>
<div className="lg:col-span-2 flex items-center gap-3">
<Button type="button" onClick={onSave} className="gap-2" disabled={saveBusy}>
{saveBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Сохранить настройки
</Button>
</div>
</div>
)
}
+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",
})
}
+1 -1
View File
File diff suppressed because one or more lines are too long