feat: enhance backups page with live data loading and backup job management

Implemented live data fetching for servers and backups on the backups page, replacing static initial data. Added functionality for manual backup creation and job status tracking, including error handling and UI updates. Updated the network map layout to improve node prioritization and visual representation of server roles.

Also, registered new backups API routes in the backend for improved data handling.
This commit is contained in:
Denozordec committed 2026-05-07 14:24:58 +07:00
1 parent 6d8379501c
commit 84ecd4f061
24 files changed
+146391 -54

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
import { randomUUID } from "node:crypto"
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import path from "node:path"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { MikrotikClient } from "../services/mikrotik.js"
type BackupMeta = {
id: string
serverId: string
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual"
notes?: string
}
type BackupJobStatus = "queued" | "running" | "done" | "failed"
type BackupJob = {
id: string
status: BackupJobStatus
requestedAt: string
startedAt?: string
finishedAt?: string
total: number
completed: number
created: BackupMeta[]
failures: Array<{ serverId: string; error: string }>
}
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
const INDEX_PATH = path.join(BACKUPS_DIR, "index.json")
const backupJobs = new Map<string, BackupJob>()
async function ensureStorage() {
await mkdir(BACKUPS_DIR, { recursive: true })
}
async function readIndex(): Promise<BackupMeta[]> {
await ensureStorage()
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 []
}
}
async function writeIndex(rows: BackupMeta[]): Promise<void> {
await ensureStorage()
await writeFile(INDEX_PATH, JSON.stringify(rows, null, 2), "utf8")
}
function fmtTs(d = new Date()): string {
const p = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
const CreateBackupBodySchema = z.object({
serverIds: z.array(z.union([z.string(), z.number()])).min(1),
notes: z.string().max(500).optional(),
})
const BackupIdParamSchema = z.object({
id: z.string().min(1),
})
const BackupJobIdParamSchema = z.object({
jobId: z.string().min(1),
})
async function runBackupForServer(id: string, notes?: string): Promise<BackupMeta> {
const serverIdNum = Number.parseInt(id, 10)
if (!Number.isFinite(serverIdNum)) {
throw new Error("Невалидный id сервера")
}
const row = getServerRowById(serverIdNum)
if (!row) {
throw new Error("Сервер не найден")
}
const client = MikrotikClient.fromServer(row)
const script = await client.exportConfigScript()
const ts = fmtTs()
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
const filename = `${safeServer}_${ts}.rsc`
const filePath = path.join(BACKUPS_DIR, filename)
await writeFile(filePath, script, "utf8")
const st = await stat(filePath)
return {
id: randomUUID(),
serverId: String(row.id),
serverName: row.name,
filename,
sizeBytes: st.size,
createdAt: new Date().toISOString(),
kind: "manual",
notes,
}
}
async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
job.status = "running"
job.startedAt = new Date().toISOString()
const indexRows = await readIndex()
for (const id of ids) {
try {
const meta = await runBackupForServer(id, notes)
indexRows.unshift(meta)
job.created.push(meta)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
job.failures.push({ serverId: id, error: message })
} finally {
job.completed += 1
}
}
await writeIndex(indexRows)
job.status = "done"
job.finishedAt = new Date().toISOString()
}
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/backups", async (_req, reply) => {
const rows = await readIndex()
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
return reply.send(rows)
})
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
const existingServers = new Set(listServersRead().map((s) => String(s.id)))
const ids = [...new Set(inputIds)].filter((id) => existingServers.has(id))
if (ids.length === 0) return reply.status(400).send({ error: "Не выбраны валидные серверы" })
const jobId = randomUUID()
const job: BackupJob = {
id: jobId,
status: "queued",
requestedAt: new Date().toISOString(),
total: ids.length,
completed: 0,
created: [],
failures: [],
}
backupJobs.set(jobId, job)
queueMicrotask(() => {
void processBackupJob(job, ids, notes).catch((err) => {
job.status = "failed"
job.finishedAt = new Date().toISOString()
job.failures.push({
serverId: "job",
error: err instanceof Error ? err.message : String(err),
})
})
})
return reply.status(202).send({
jobId,
status: job.status,
total: job.total,
completed: job.completed,
})
})
app.get("/backups/jobs/:jobId", { schema: { params: BackupJobIdParamSchema } }, async (req, reply) => {
const job = backupJobs.get(req.params.jobId)
if (!job) return reply.status(404).send({ error: "Job не найден" })
return reply.send(job)
})
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const rows = await readIndex()
const hit = rows.find((r) => r.id === req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
const filePath = path.join(BACKUPS_DIR, 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)
})
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const rows = await readIndex()
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 writeIndex(rows)
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
return reply.status(204).send()
})
}
export default backupsRoutes