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
2026-05-07 14:24:58 +07:00
parent 6d8379501c
commit 84ecd4f061
24 changed files with 146391 additions and 54 deletions
+2
View File
@@ -17,6 +17,7 @@ import probesRoutes from "./routes/probes.js"
import schedulerRoutes from "./routes/scheduler.js"
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
import alertsRoutes from "./routes/alerts.js"
import backupsRoutes from "./routes/backups.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
// ── app factory ────────────────────────────────────────────────────────────────
@@ -60,6 +61,7 @@ await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
refreshScheduler()
app.addHook("onClose", async () => {
+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
+112
View File
@@ -430,6 +430,118 @@ export class MikrotikClient {
}
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
}
async exportConfigScript(): Promise<string> {
const raw = await this.post<unknown>("/console/export", {}, 30_000)
const asText = (v: unknown): string | null => {
if (typeof v === "string") return v.trim().length > 0 ? v : null
if (Array.isArray(v)) {
const parts = v
.map((item) => asText(item))
.filter((s): s is string => typeof s === "string" && s.length > 0)
return parts.length > 0 ? parts.join("\n") : null
}
if (v && typeof v === "object") {
const rec = v as Record<string, unknown>
const direct =
asText(rec.output) ??
asText(rec.stdout) ??
asText(rec.data) ??
asText(rec.ret) ??
asText(rec["!re"])
if (direct) return direct
const serialized = JSON.stringify(rec, null, 2)
return serialized.length > 2 ? serialized : null
}
return null
}
const txt = asText(raw)
if (txt && txt.trim().length > 0) return txt
// Fallback: на части RouterOS /console/export возвращает пустое тело.
// Тогда строим .rsc-скрипт из основных read-only разделов REST.
return this.buildSyntheticExportScript()
}
private async buildSyntheticExportScript(): Promise<string> {
const now = new Date().toISOString()
const lines: string[] = [
"# synthetic export generated by MikrotikManager",
`# generated-at: ${now}`,
"",
]
const identity = await this.getIdentity().catch(() => null)
if (identity?.name) {
lines.push("/system identity")
lines.push(`set name="${identity.name.replace(/"/g, "\\\"")}"`)
lines.push("")
}
const interfaces = await this.getInterfaces().catch(() => [])
if (interfaces.length > 0) {
lines.push("/interface")
for (const i of interfaces) {
if (!i.name) continue
const mtu = i["actual-mtu"] ?? i.mtu
const parts = [
`name="${String(i.name).replace(/"/g, "\\\"")}"`,
mtu ? `mtu=${mtu}` : null,
i.disabled === "true" ? "disabled=yes" : "disabled=no",
].filter((v): v is string => typeof v === "string")
lines.push(`:put "interface ${parts.join(" ")}"`)
}
lines.push("")
}
const addrs = await this.getIpAddresses().catch(() => [])
if (addrs.length > 0) {
lines.push("/ip address")
for (const a of addrs) {
if (!a.address || !a.interface) continue
const comment = a.comment ? ` comment="${String(a.comment).replace(/"/g, "\\\"")}"` : ""
lines.push(`add address=${a.address} interface="${String(a.interface).replace(/"/g, "\\\"")}"${comment}`)
}
lines.push("")
}
const routes = await this.getIpRoutes().catch(() => [])
if (routes.length > 0) {
lines.push("/ip route")
for (const r of routes) {
const dst = r["dst-address"]
const gw = r["gateway"]
if (!dst || !gw) continue
const distance = r.distance ? ` distance=${r.distance}` : ""
lines.push(`add dst-address=${dst} gateway=${gw}${distance}`)
}
lines.push("")
}
const firewall = await this.getFirewallFilters().catch(() => [])
if (firewall.length > 0) {
lines.push("/ip firewall filter")
for (const f of firewall) {
if (!f.chain || !f.action) continue
const parts = [`chain=${f.chain}`, `action=${f.action}`]
if (f.protocol) parts.push(`protocol=${f.protocol}`)
if (f["src-address"]) parts.push(`src-address=${f["src-address"]}`)
if (f["dst-address"]) parts.push(`dst-address=${f["dst-address"]}`)
if (f["dst-port"]) parts.push(`dst-port=${f["dst-port"]}`)
if (f["src-port"]) parts.push(`src-port=${f["src-port"]}`)
if (f.disabled === "true") parts.push("disabled=yes")
lines.push(`add ${parts.join(" ")}`)
}
lines.push("")
}
if (lines.length <= 3) {
throw new Error("RouterOS вернул пустой export и fallback-данные недоступны")
}
return lines.join("\n")
}
}
// ── Error type ─────────────────────────────────────────────────────────────────