From 9ab2418a5f2745dab919e390c70a9187a05a5ba1 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 12 May 2026 21:56:13 +0700 Subject: [PATCH] =?UTF-8?q?feat(backup):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=83?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B9=20=D0=B8=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.gitignore | 1 + backend/src/db/index.ts | 50 ++++++++ backend/src/db/schema.ts | 12 ++ backend/src/routes/backups.ts | 25 ++-- .../services/backup-scheduler-collector.ts | 8 +- backend/src/services/backup-service.ts | 120 +++++++++++------- 6 files changed, 148 insertions(+), 68 deletions(-) diff --git a/backend/.gitignore b/backend/.gitignore index 467cd39..5460d7e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -4,3 +4,4 @@ dist/ *.db-shm *.db-wal .env +storage/backups/ diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index c84501d..ffe7a49 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,4 +1,6 @@ import Database from "better-sqlite3" +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" type SqliteHandle = InstanceType import { drizzle } from "drizzle-orm/better-sqlite3" @@ -391,6 +393,19 @@ CREATE TABLE IF NOT EXISTS backup_schedule_settings ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS backup_entries ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL, + server_name TEXT NOT NULL, + filename TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + kind TEXT NOT NULL DEFAULT 'manual', + notes TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename); + CREATE TABLE IF NOT EXISTS alert_rules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -669,6 +684,41 @@ SELECT 1, NULL WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1); `) +const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number } +if (backupEntryCount.c === 0) { + const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json") + if (existsSync(legacyIndexPath)) { + try { + const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown + if (Array.isArray(parsed)) { + const insert = sqlite.prepare(` + INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at) + VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt) + `) + for (const row of parsed) { + if (!row || typeof row !== "object") continue + const item = row as Record + const id = String(item.id ?? "").trim() + const filename = String(item.filename ?? "").trim() + if (!id || !filename) continue + insert.run({ + id, + serverId: String(item.serverId ?? ""), + serverName: String(item.serverName ?? ""), + filename, + sizeBytes: Number(item.sizeBytes ?? 0) || 0, + kind: item.kind === "auto" ? "auto" : "manual", + notes: item.notes == null ? null : String(item.notes), + createdAt: String(item.createdAt ?? new Date().toISOString()), + }) + } + } + } catch { + /* legacy index.json не читается — пропускаем */ + } + } +} + export const db = drizzle(sqlite, { schema }) /** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */ diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index c161921..a22655d 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -340,6 +340,17 @@ export const backupScheduleSettings = sqliteTable("backup_schedule_settings", { updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), }) +export const backupEntries = sqliteTable("backup_entries", { + id: text("id").primaryKey(), + serverId: text("server_id").notNull(), + serverName: text("server_name").notNull(), + filename: text("filename").notNull(), + sizeBytes: integer("size_bytes").notNull(), + kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"), + notes: text("notes"), + createdAt: text("created_at").notNull(), +}) + /** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */ export const alertGroups = sqliteTable("alert_groups", { id: text("id").primaryKey(), @@ -562,6 +573,7 @@ export type AcmeSettingsRow = typeof acmeSettings.$inferSelect export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect export type CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$inferSelect +export type BackupEntryRow = typeof backupEntries.$inferSelect export type AlertGroupRow = typeof alertGroups.$inferSelect export type AlertRuleRow = typeof alertRules.$inferSelect export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts index cb8dd97..342b472 100644 --- a/backend/src/routes/backups.ts +++ b/backend/src/routes/backups.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto" -import { readFile, rm } from "node:fs/promises" +import { readFile } from "node:fs/promises" import path from "node:path" import { z } from "zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" @@ -8,12 +8,13 @@ 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, - readBackupIndex, + listBackups, runBackupForServer, updateBackupScheduleSettings, - writeBackupIndex, type BackupMeta, } from "../services/backup-service.js" @@ -47,11 +48,9 @@ const BackupJobIdParamSchema = z.object({ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) { job.status = "running" job.startedAt = new Date().toISOString() - const indexRows = await readBackupIndex() for (const id of ids) { try { const meta = await runBackupForServer(id, "manual", notes) - indexRows.unshift(meta) job.created.push(meta) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -60,7 +59,6 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) { job.completed += 1 } } - await writeBackupIndex(indexRows) job.status = "done" job.finishedAt = new Date().toISOString() appendEvent({ @@ -82,9 +80,7 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) { const backupsRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/backups", async (_req, reply) => { - const rows = await readBackupIndex() - rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - return reply.send(rows) + return reply.send(listBackups()) }) app.get("/backups/schedule", async (_req, reply) => { @@ -171,8 +167,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { }) app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => { - const rows = await readBackupIndex() - const hit = rows.find((r) => r.id === req.params.id) + const hit = 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) @@ -183,12 +178,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => { }) app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => { - const rows = await readBackupIndex() - const idx = rows.findIndex((r) => r.id === req.params.id) - if (idx < 0) return reply.status(404).send({ error: "Бэкап не найден" }) - const [hit] = rows.splice(idx, 1) - await writeBackupIndex(rows) - await rm(path.join(getBackupsDir(), hit.filename), { force: true }) + const hit = await deleteBackupRecord(req.params.id) + if (!hit) return reply.status(404).send({ error: "Бэкап не найден" }) return reply.status(204).send() }) } diff --git a/backend/src/services/backup-scheduler-collector.ts b/backend/src/services/backup-scheduler-collector.ts index d2940b8..941144c 100644 --- a/backend/src/services/backup-scheduler-collector.ts +++ b/backend/src/services/backup-scheduler-collector.ts @@ -5,11 +5,9 @@ import { getBackupScheduleSettings, isBackupDue, pruneBackupsForServer, - readBackupIndex, resolveBackupServerIds, runBackupForServer, touchBackupScheduleRunMeta, - writeBackupIndex, } from "./backup-service.js" let collecting = false @@ -62,7 +60,6 @@ export async function collectScheduledBackupsOnce(): Promise collecting = true const started = Date.now() const serverIds = resolveBackupServerIds(settings) - const indexRows = await readBackupIndex() appendEvent({ level: "info", @@ -78,8 +75,7 @@ export async function collectScheduledBackupsOnce(): Promise try { for (const id of serverIds) { try { - const meta = await runBackupForServer(id, "auto") - indexRows.unshift(meta) + await runBackupForServer(id, "auto") snapshot.created += 1 } catch (e) { const message = e instanceof Error ? e.message : String(e) @@ -88,8 +84,6 @@ export async function collectScheduledBackupsOnce(): Promise } } - await writeBackupIndex(indexRows) - for (const id of serverIds) { snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount) } diff --git a/backend/src/services/backup-service.ts b/backend/src/services/backup-service.ts index 8195e8c..df89ed8 100644 --- a/backend/src/services/backup-service.ts +++ b/backend/src/services/backup-service.ts @@ -1,17 +1,16 @@ import { randomUUID } from "node:crypto" -import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises" +import { mkdir, rm, stat, writeFile } from "node:fs/promises" import path from "node:path" -import { eq } from "drizzle-orm" +import { desc, eq } from "drizzle-orm" import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups" import { db } from "../db/index.js" -import { backupScheduleSettings } from "../db/schema.js" +import { backupEntries, backupScheduleSettings } from "../db/schema.js" import { getServerRowById } from "../modules/servers/repository/servers-repository.js" import { listServersRead } from "../modules/servers/service/servers-service.js" import { MikrotikClient } from "./mikrotik.js" const SETTINGS_ID = 1 const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups") -const INDEX_PATH = path.join(BACKUPS_DIR, "index.json") export type BackupMeta = { id: string @@ -24,25 +23,51 @@ export type BackupMeta = { notes?: string } +function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta { + return { + id: row.id, + serverId: row.serverId, + serverName: row.serverName, + filename: row.filename, + sizeBytes: row.sizeBytes, + createdAt: row.createdAt, + kind: row.kind, + notes: row.notes ?? undefined, + } +} + export async function ensureBackupStorage(): Promise { await mkdir(BACKUPS_DIR, { recursive: true }) } -export async function readBackupIndex(): Promise { - await ensureBackupStorage() - try { - const raw = await readFile(INDEX_PATH, "utf8") - const parsed = JSON.parse(raw) as unknown - if (!Array.isArray(parsed)) return [] - return parsed as BackupMeta[] - } catch { - return [] - } +export function listBackups(): BackupMeta[] { + return db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt)).all().map(rowToMeta) } -export async function writeBackupIndex(rows: BackupMeta[]): Promise { - await ensureBackupStorage() - await writeFile(INDEX_PATH, JSON.stringify(rows, null, 2), "utf8") +export function getBackupById(id: string): BackupMeta | null { + const row = db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1).all()[0] + return row ? rowToMeta(row) : null +} + +export function insertBackup(meta: BackupMeta): void { + 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, + createdAt: meta.createdAt, + }).run() +} + +export async function deleteBackupRecord(id: string): Promise { + const hit = getBackupById(id) + if (!hit) return null + db.delete(backupEntries).where(eq(backupEntries.id, id)).run() + await rm(path.join(BACKUPS_DIR, hit.filename), { force: true }) + return hit } function fmtTs(d = new Date()): string { @@ -152,7 +177,22 @@ export function resolveBackupServerIds(settings: BackupScheduleSettingsDto): str return [...new Set(requested)].filter((id) => enabled.has(id)) } -function sameLocalSlot(a: Date, b: Date): boolean { +function scheduledSlotForDate(now: Date, settings: BackupScheduleSettingsDto): Date | null { + if (settings.frequency === "weekly") { + const currentDow = (now.getDay() + 6) % 7 + if (currentDow !== settings.weekDay) return null + } else if (settings.frequency === "monthly") { + if (now.getDate() !== settings.monthDay) return null + } + + const slot = new Date(now) + slot.setSeconds(0, 0) + slot.setMilliseconds(0) + slot.setHours(settings.hour, settings.minute, 0, 0) + return slot +} + +function sameLocalMinute(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() @@ -160,27 +200,21 @@ function sameLocalSlot(a: Date, b: Date): boolean { && a.getMinutes() === b.getMinutes() } +/** Срабатывает только в минуту расписания; 60 с в UI — интервал проверки, не частота бэкапа. */ export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean { if (!settings.enabled) return false - const slot = new Date(now) - slot.setSeconds(0, 0) - slot.setHours(settings.hour, settings.minute, 0, 0) - - if (settings.frequency === "weekly") { - const currentDow = (now.getDay() + 6) % 7 - if (currentDow !== settings.weekDay) return false - } else if (settings.frequency === "monthly") { - if (now.getDate() !== settings.monthDay) return false - } + const slot = scheduledSlotForDate(now, settings) + if (!slot) return false if (now < slot) return false + if (!sameLocalMinute(now, slot)) return false + if (lastRunAt) { const prev = new Date(lastRunAt) if (Number.isNaN(prev.getTime())) return true - if (settings.frequency === "daily" && sameLocalSlot(prev, slot)) return false - if (settings.frequency === "weekly" && sameLocalSlot(prev, slot)) return false - if (settings.frequency === "monthly" && prev.getFullYear() === slot.getFullYear() && prev.getMonth() === slot.getMonth() && prev.getDate() === slot.getDate()) return false + if (sameLocalMinute(prev, slot)) return false } + return true } @@ -203,9 +237,10 @@ export async function runBackupForServer( const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_") const filename = `${safeServer}_${ts}.rsc` const filePath = path.join(BACKUPS_DIR, filename) + await ensureBackupStorage() await writeFile(filePath, script, "utf8") const st = await stat(filePath) - return { + const meta: BackupMeta = { id: randomUUID(), serverId: String(row.id), serverName: row.name, @@ -215,27 +250,24 @@ export async function runBackupForServer( kind, notes, } + insertBackup(meta) + return meta } export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise { - const rows = await readBackupIndex() - const forServer = rows.filter((r) => r.serverId === serverId) - if (forServer.length <= keepCount) return 0 - const sorted = [...forServer].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - const toDelete = sorted.slice(keepCount) - const deleteIds = new Set(toDelete.map((r) => r.id)) + const rows = db.select().from(backupEntries) + .where(eq(backupEntries.serverId, serverId)) + .orderBy(desc(backupEntries.createdAt)) + .all() + if (rows.length <= keepCount) return 0 + const toDelete = rows.slice(keepCount) for (const hit of toDelete) { + db.delete(backupEntries).where(eq(backupEntries.id, hit.id)).run() await rm(path.join(BACKUPS_DIR, hit.filename), { force: true }) } - const next = rows.filter((r) => !deleteIds.has(r.id)) - await writeBackupIndex(next) return toDelete.length } export function getBackupsDir(): string { return BACKUPS_DIR } - -export function getBackupIndexPath(): string { - return INDEX_PATH -}