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:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user