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
+162 -38
View File
@@ -1,10 +1,9 @@
"use client"
import { useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { StatusBadge } from "@/components/status-badge"
import { backups as initialBackups, servers } from "@/lib/data"
import type { Backup } from "@/lib/data"
import type { Backup, Server } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -18,6 +17,10 @@ import {
FolderIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
// ─── small UI helpers ─────────────────────────────────────────────────────────
@@ -102,10 +105,16 @@ const defaultStorage = {
// ════════════════════════════════════════════════════════════════════════════
export default function BackupsPage() {
const { backendUrl } = useDataSource()
// ── state ──────────────────────────────────────────────────────────────
const [tab, setTab] = useState<PageTab>("history")
const [backupList, setBackupList] = useState<Backup[]>(initialBackups)
const [backupList, setBackupList] = useState<Backup[]>([])
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [opBusy, setOpBusy] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [backupJobId, setBackupJobId] = useState<string | null>(null)
// Schedule
const [schedule, setSchedule] = useState(defaultSchedule)
@@ -119,7 +128,7 @@ export default function BackupsPage() {
// Server selection (all enabled by default)
const [selectedServers, setSelectedServers] = useState<Set<string>>(
new Set(servers.map((s) => s.id))
new Set()
)
function toggleServer(id: string) {
setSelectedServers((prev) => {
@@ -147,31 +156,118 @@ export default function BackupsPage() {
return next
})
}
function handleManualBackup() {
const now = new Date()
const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")}_${String(now.getHours()).padStart(2,"0")}-${String(now.getMinutes()).padStart(2,"0")}`
const newBackups: Backup[] = Array.from(manualServers).map((sid, i) => {
const sv = servers.find((s) => s.id === sid)!
return {
id: `b${Date.now()}${i}`,
server: sv.name,
filename: `${sv.name}_${ts}_manual.rsc`,
size: `${Math.floor(60 + Math.random()*80)} КБ`,
created: "Только что",
kind: "manual",
notes: manualNotes || "",
}
})
setBackupList((list) => [...newBackups, ...list])
setManualOpen(false)
setManualServers(new Set())
setManualNotes("")
setTab("history")
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
const kb = Math.max(1, Math.round(b.sizeBytes / 1024))
return {
id: b.id,
server: b.serverName,
filename: b.filename,
size: `${kb} КБ`,
created: new Date(b.createdAt).toLocaleString("ru-RU"),
kind: b.kind,
notes: b.notes ?? "",
}
}, [])
const loadLive = useCallback(async () => {
setLoading(true)
setOpError(null)
try {
const [serversRows, backupsRows] = await Promise.all([
listServers(backendUrl),
listBackups(backendUrl),
])
const mappedServers = serversRows.map((s) => toFrontendServer(s))
setLiveServers(mappedServers)
setBackupList(backupsRows.map(mapApiBackupToUi))
setSelectedServers(new Set(mappedServers.map((s) => s.id)))
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
} finally {
setLoading(false)
}
}, [backendUrl, mapApiBackupToUi])
useEffect(() => {
void loadLive()
}, [loadLive])
async function handleManualBackup() {
if (manualServers.size === 0 || opBusy) return
setOpError(null)
try {
const res = await createBackupsAsync(backendUrl, {
serverIds: [...manualServers],
notes: manualNotes || undefined,
})
setBackupJobId(res.jobId)
setManualOpen(false)
setManualServers(new Set())
setManualNotes("")
setTab("history")
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа")
}
}
useEffect(() => {
if (!backupJobId) return
let cancelled = false
const timer = setInterval(() => {
void (async () => {
try {
const job = await getBackupJob(backendUrl, backupJobId)
if (cancelled) return
if (job.status === "done") {
clearInterval(timer)
setBackupJobId(null)
if (job.failures.length > 0) {
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
}
await loadLive()
} else if (job.status === "failed") {
clearInterval(timer)
setBackupJobId(null)
setOpError(job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ") || "Ошибка фоновой задачи бэкапа")
await loadLive()
}
} catch (e) {
clearInterval(timer)
setBackupJobId(null)
setOpError(e instanceof Error ? e.message : "Ошибка опроса задачи бэкапа")
}
})()
}, 1200)
return () => {
cancelled = true
clearInterval(timer)
}
}, [backendUrl, backupJobId, loadLive])
// Delete backup
function handleDelete(id: string) {
setBackupList((list) => list.filter((b) => b.id !== id))
async function handleDelete(id: string) {
setOpBusy(true)
setOpError(null)
try {
await deleteBackup(backendUrl, id)
await loadLive()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа")
} finally {
setOpBusy(false)
}
}
async function handleDownload(id: string, fallbackFilename: string) {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
if (!res.ok) throw new Error("Не удалось скачать файл")
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = fallbackFilename
a.click()
URL.revokeObjectURL(url)
}
// ── derived ────────────────────────────────────────────────────────────
@@ -205,10 +301,22 @@ export default function BackupsPage() {
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => { setManualServers(new Set(servers.map(s=>s.id))); setManualOpen(true) }}>
<Button
variant="outline"
size="sm"
onClick={() => {
setManualServers(new Set(liveServers.map((s) => s.id)))
setManualOpen(true)
}}
disabled={loading}
>
<RefreshCwIcon className="size-4" />Снять со всех
</Button>
<Button size="sm" onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}>
<Button
size="sm"
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
disabled={loading}
>
<PlusIcon className="size-4" />Новый бэкап
</Button>
</>
@@ -252,13 +360,23 @@ export default function BackupsPage() {
<span className="h-3 w-px bg-border" />
<span className="flex items-center gap-1.5">
<ServerIcon className="size-3" />
{selectedServers.size} из {servers.length} серверов
{selectedServers.size} из {liveServers.length} серверов
</span>
<button onClick={() => setTab("settings")}
className="ml-auto text-xs text-primary hover:underline">
Изменить настройки
</button>
</div>
{opError && (
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{opError}
</div>
)}
{backupJobId && (
<div className="rounded-md border border-blue-500/30 bg-blue-500/10 px-3 py-2 text-sm text-blue-300">
Бэкап выполняется в фоне...
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-border">
@@ -328,14 +446,20 @@ export default function BackupsPage() {
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="size-7" title="Скачать">
<Button
variant="ghost"
size="icon"
className="size-7"
title="Скачать"
onClick={() => void handleDownload(b.id, b.filename)}
>
<DownloadIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
<RefreshCwIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
title="Удалить" onClick={() => handleDelete(b.id)}>
title="Удалить" onClick={() => void handleDelete(b.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
@@ -548,7 +672,7 @@ export default function BackupsPage() {
<div className="flex items-center justify-between">
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={() => setSelectedServers(new Set(servers.map(s => s.id)))}
<button type="button" onClick={() => setSelectedServers(new Set(liveServers.map(s => s.id)))}
className="text-xs text-primary hover:underline">Выбрать все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setSelectedServers(new Set())}
@@ -557,7 +681,7 @@ export default function BackupsPage() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{servers.map((s) => {
{liveServers.map((s) => {
const checked = selectedServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleServer(s.id)}
@@ -586,7 +710,7 @@ export default function BackupsPage() {
</div>
<p className="text-xs text-muted-foreground">
Выбрано {selectedServers.size} из {servers.length} серверов
Выбрано {selectedServers.size} из {liveServers.length} серверов
</p>
</CardContent>
</Card>
@@ -622,14 +746,14 @@ export default function BackupsPage() {
<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={() => setManualServers(new Set(servers.map(s=>s.id)))}
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
className="text-xs text-primary hover:underline">Все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setManualServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
</div>
</div>
{servers.map((s) => {
{liveServers.map((s) => {
const checked = manualServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
@@ -668,7 +792,7 @@ export default function BackupsPage() {
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1"
disabled={manualServers.size === 0}
disabled={manualServers.size === 0 || backupJobId !== null}
onClick={handleManualBackup}>
Снять бэкап ({manualServers.size})
</Button>
+20 -5
View File
@@ -246,9 +246,12 @@ const STATUS_STYLE = {
}
const TYPE_STYLE: Record<ServerType, { label: string; fill: string; r: number }> = {
"jump-host": { label: "JH", fill: "#7c3aed", r: 36 },
"exit-node": { label: "EN", fill: "#0369a1", r: 36 },
"home-router": { label: "HR", fill: "#16a34a", r: 30 },
// HR — визуальный корень топологии: крупнее и с более сильным акцентом.
"home-router": { label: "HR", fill: "#16a34a", r: 40 },
// JH — агрегирующий слой по центру.
"jump-host": { label: "JH", fill: "#7c3aed", r: 34 },
// EN — крайние edge-узлы, менее акцентные.
"exit-node": { label: "EN", fill: "#0369a1", r: 30 },
}
const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"]
@@ -886,7 +889,10 @@ export default function NetworkMapPage() {
})
}, [useLiveData, loadLive])
const autoLayout = useMemo(() => computeNetworkMapLayout(mapServers), [mapServers])
const autoLayout = useMemo(
() => computeNetworkMapLayout(mapServers, mapGreTunnels, greResolvedMap),
[mapServers, mapGreTunnels, greResolvedMap],
)
const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers])
const srvResMap = useMemo(() => buildServerResourceMap(mapServers), [mapServers])
const homeRouters = useMemo(
@@ -1099,7 +1105,16 @@ export default function NetworkMapPage() {
return out
}, [greEdges])
const nodes = mapServers.map((s) => ({ ...s, ...nodePosById[s.id]! }))
const nodes = mapServers
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
// Визуальный приоритет: HR поверх JH, JH поверх EN.
.sort((a, b) => {
const weight = (t: ServerType) =>
t === "home-router" ? 3 :
t === "jump-host" ? 2 :
1
return weight(a.type) - weight(b.type)
})
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
// ── Refs ─────────────────────────────────────────────────────────────────────
+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 ─────────────────────────────────────────────────────────────────
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
[
{
"id": "7d46ee5b-c829-4d42-af90-847026bafd8a",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-07_14-19-22.rsc",
"sizeBytes": 676415,
"createdAt": "2026-05-07T07:19:22.073Z",
"kind": "manual"
},
{
"id": "c34f71e5-6766-456b-9b8a-9b8efaa91edc",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-07_14-19-19.rsc",
"sizeBytes": 672268,
"createdAt": "2026-05-07T07:19:19.639Z",
"kind": "manual"
},
{
"id": "40e253a1-98a8-40a7-982b-7c5af213f490",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-07_14-19-17.rsc",
"sizeBytes": 2131,
"createdAt": "2026-05-07T07:19:17.801Z",
"kind": "manual"
},
{
"id": "9199d0a8-9bc0-4d20-8498-b37f1f15262b",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-07_14-19-17.rsc",
"sizeBytes": 675732,
"createdAt": "2026-05-07T07:19:17.272Z",
"kind": "manual"
},
{
"id": "4de5263f-edb4-4ca9-ae90-2247defdc05e",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-19-14.rsc",
"sizeBytes": 913056,
"createdAt": "2026-05-07T07:19:14.772Z",
"kind": "manual"
},
{
"id": "de49d319-a24f-475c-bb6e-8075eff86380",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-16-45.rsc",
"sizeBytes": 911521,
"createdAt": "2026-05-07T07:16:45.543Z",
"kind": "manual",
"notes": "async"
},
{
"id": "ec5b9e31-42cb-40b9-aba7-3501d5145713",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-07_14-13-58.rsc",
"sizeBytes": 676415,
"createdAt": "2026-05-07T07:13:58.749Z",
"kind": "manual"
},
{
"id": "7909c7de-a548-44d4-bdf7-7c221bfedd36",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-07_14-13-56.rsc",
"sizeBytes": 672268,
"createdAt": "2026-05-07T07:13:56.245Z",
"kind": "manual"
},
{
"id": "63339ebb-eb94-455e-a61b-368523fed7e1",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-07_14-13-54.rsc",
"sizeBytes": 2131,
"createdAt": "2026-05-07T07:13:54.493Z",
"kind": "manual"
},
{
"id": "daccab1d-f60a-4570-9d11-c7b06491f6f7",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-07_14-13-53.rsc",
"sizeBytes": 675732,
"createdAt": "2026-05-07T07:13:53.791Z",
"kind": "manual"
},
{
"id": "d976cae6-aae8-4f55-9452-71d5480ac8e8",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-13-48.rsc",
"sizeBytes": 913056,
"createdAt": "2026-05-07T07:13:48.326Z",
"kind": "manual"
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-07T07:13:21.880Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
@@ -0,0 +1,44 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-07T07:13:54.118Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
@@ -0,0 +1,44 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-07T07:19:17.424Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+209 -11
View File
@@ -42,11 +42,33 @@ const MARGIN = 72
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
export const NETWORK_MAP_PIPELINE_Y = 300
type NodeRole = "HR" | "JH" | "EN"
function roleOfServer(s: Server): NodeRole | null {
if (s.type === "home-router") return "HR"
if (s.type === "jump-host") return "JH"
if (s.type === "exit-node") return "EN"
return null
}
function roleLayer(role: NodeRole): number {
switch (role) {
case "HR": return 1
case "JH": return 2
case "EN": return 3
}
}
function layerOfServer(s: Server): number | null {
const r = roleOfServer(s)
return r ? roleLayer(r) : null
}
/**
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
*/
export const NETWORK_MAP_LAYOUT_REVISION = 5
export const NETWORK_MAP_LAYOUT_REVISION = 6
export interface WanJhEdge {
homeId: string
@@ -141,8 +163,91 @@ function resolveLaneCollisions(
}
}
/** Детерминированные координаты узлов и «спутников» WAN под home-router. */
export function computeNetworkMapLayout(servers: Server[]): {
/** Детерминированные координаты узлов и «спутников» WAN под home-router.
*
* Важно: раскладка строго иерархическая по ролям:
* - HR (home-router) слева,
* - JH (jump-host) по центру,
* - EN (exit-node) справа.
*
* Это не force-layout: координаты полностью детерминированы по составу servers.
*/
function average(nums: number[]): number | null {
if (nums.length === 0) return null
return nums.reduce((acc, n) => acc + n, 0) / nums.length
}
function alignLayerCenterY(
ids: string[],
nodePos: Record<string, { x: number; y: number }>,
targetCenterY: number,
minY: number,
maxY: number,
): void {
if (ids.length === 0) return
const ys = ids
.map((id) => nodePos[id]?.y)
.filter((v): v is number => typeof v === "number")
const currentCenter = average(ys)
if (currentCenter == null) return
const delta = targetCenterY - currentCenter
for (const id of ids) {
const p = nodePos[id]
if (!p) continue
p.y = clamp(p.y + delta, minY, maxY)
}
}
type HierarchyLinks = {
hrToJh: Map<string, Set<string>>
jhToEn: Map<string, Set<string>>
hrToEn: Map<string, Set<string>>
}
function buildHierarchyLinks(
servers: Server[],
tunnels: GreTunnel[],
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): HierarchyLinks {
const hrToJh = new Map<string, Set<string>>()
const jhToEn = new Map<string, Set<string>>()
const hrToEn = new Map<string, Set<string>>()
const byId = new Map(servers.map((s) => [s.id, s] as const))
function add(map: Map<string, Set<string>>, from: string, to: string) {
const set = map.get(from) ?? new Set<string>()
set.add(to)
map.set(from, set)
}
for (const t of tunnels) {
const a = byId.get(String(t.serverId))
const b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
if (!a || !b || a.id === b.id) continue
const la = layerOfServer(a)
const lb = layerOfServer(b)
if (la == null || lb == null || la === lb) continue
const from = la < lb ? a : b
const to = la < lb ? b : a
const fr = roleOfServer(from)
const tr = roleOfServer(to)
if (!fr || !tr) continue
if (fr === "HR" && tr === "JH") add(hrToJh, from.id, to.id)
else if (fr === "JH" && tr === "EN") add(jhToEn, from.id, to.id)
else if (fr === "HR" && tr === "EN") add(hrToEn, from.id, to.id)
}
return { hrToJh, jhToEn, hrToEn }
}
export function computeNetworkMapLayout(
servers: Server[],
tunnels: GreTunnel[] = [],
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): {
nodePos: Record<string, { x: number; y: number }>
wanSatPos: Record<string, { x: number; y: number }[]>
} {
@@ -153,6 +258,10 @@ export function computeNetworkMapLayout(servers: Server[]): {
const laneGap = Math.min(44, span * 0.04)
const laneW = (span - 2 * laneGap) / 3
// Жёсткое позиционирование по иерархическим слоям:
// - HR: левая полоса (0–20%),
// - JH: центральная (40–60%),
// - EN: правая (80100%).
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
const laneByType = new Map<ServerType, Server[]>()
const minNodeY = MARGIN + 70
@@ -193,30 +302,102 @@ export function computeNetworkMapLayout(servers: Server[]): {
.slice()
.sort((a, b) => `${a.site}|${a.name}|${a.id}`.localeCompare(`${b.site}|${b.name}|${b.id}`))
laneByType.set(type, group)
const xMin = xLane
const xMax = xLane + laneW
const laneCenterX = (xMin + xMax) / 2
group.forEach((s, i) => {
group.forEach((s) => {
const baseline = siteY.get(s.site) ?? fallbackY
const siblings = group.filter((g) => g.site === s.site)
const sibIdx = siblings.findIndex((g) => g.id === s.id)
const spread = siblings.length <= 1 ? 0 : (sibIdx - (siblings.length - 1) / 2) * 34
nodePos[s.id] = {
// Жёсткие колонки: Gateway слева, JH по центру, EN справа.
x: laneCenterX,
y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
}
})
xLane += laneW + laneGap
}
const homes = laneByType.get("home-router") ?? []
const jhs = laneByType.get("jump-host") ?? []
const exits = laneByType.get("exit-node") ?? []
const links = buildHierarchyLinks(servers, tunnels, resolvedIpv4ByHost)
const hrParentsByJh = new Map<string, string[]>()
for (const [hrId, jhSet] of links.hrToJh.entries()) {
for (const jhId of jhSet) {
const arr = hrParentsByJh.get(jhId) ?? []
arr.push(hrId)
hrParentsByJh.set(jhId, arr)
}
}
// JH тянем по Y к HR-родителям, чтобы хабы не "плавали" от site-сортировки.
for (const jh of jhs) {
const parentIds = hrParentsByJh.get(jh.id) ?? []
const parentYs = parentIds
.map((id) => nodePos[id]?.y)
.filter((v): v is number => typeof v === "number")
const target = average(parentYs)
if (target != null && nodePos[jh.id]) {
nodePos[jh.id]!.y = clamp(target, minNodeY, maxNodeY)
}
}
resolveLaneCollisions(homes.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
resolveLaneCollisions(jhs.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
const jhParentsByEn = new Map<string, string[]>()
for (const [jhId, enSet] of links.jhToEn.entries()) {
for (const enId of enSet) {
const arr = jhParentsByEn.get(enId) ?? []
arr.push(jhId)
jhParentsByEn.set(enId, arr)
}
}
const hrParentsByEn = new Map<string, string[]>()
for (const [hrId, enSet] of links.hrToEn.entries()) {
for (const enId of enSet) {
const arr = hrParentsByEn.get(enId) ?? []
arr.push(hrId)
hrParentsByEn.set(enId, arr)
}
}
// EN вешаем на JH-родителей (или HR fallback), чтобы получать читаемую правую "leaf"-ветку.
for (const en of exits) {
const jhParentIds = jhParentsByEn.get(en.id) ?? []
const jhParentYs = jhParentIds
.map((id) => nodePos[id]?.y)
.filter((v): v is number => typeof v === "number")
const jhTarget = average(jhParentYs)
if (jhTarget != null && nodePos[en.id]) {
nodePos[en.id]!.y = clamp(jhTarget, minNodeY, maxNodeY)
continue
}
const hrParentIds = hrParentsByEn.get(en.id) ?? []
const hrParentYs = hrParentIds
.map((id) => nodePos[id]?.y)
.filter((v): v is number => typeof v === "number")
const hrTarget = average(hrParentYs)
if (hrTarget != null && nodePos[en.id]) {
nodePos[en.id]!.y = clamp(hrTarget, minNodeY, maxNodeY)
}
}
resolveLaneCollisions(exits.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
// EN-слой выравниваем относительно центра HR-слоя:
// карта читается как "ingress слева -> leaf справа" на одной оси.
const hrCenterY = average(
homes
.map((s) => nodePos[s.id]?.y)
.filter((v): v is number => typeof v === "number"),
)
if (hrCenterY != null) {
alignLayerCenterY(exits.map((s) => s.id), nodePos, hrCenterY, minNodeY, maxNodeY)
resolveLaneCollisions(exits.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
}
const hr = homes
/** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2
@@ -227,9 +408,11 @@ export function computeNetworkMapLayout(servers: Server[]): {
const n = wans.length
const satX = (base.x + jhColumnCenterX) / 2
const rowY = base.y
const maxV = H - 2 * MARGIN - 100
const maxV = H - 2 * MARGIN - 96
// Разносим WAN-аплинки по высоте заметнее для читаемости подписей/бейджей.
const preferredGap = 140
const vGap =
n <= 1 ? 0 : Math.min(56, maxV / Math.max(1, n - 1))
n <= 1 ? 0 : Math.min(preferredGap, maxV / Math.max(1, n - 1))
const positions: { x: number; y: number }[] = []
for (let i = 0; i < n; i++) {
const rawY = n === 1 ? rowY : rowY + (i - (n - 1) / 2) * vGap
@@ -583,10 +766,25 @@ export function buildGreMapEdges(
}
for (const t of tunnels) {
const fromServer = servers.find((s) => s.id === t.serverId)
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
if (!fromServer || !toServer) continue
if (fromServer.id === toServer.id) continue
let a = servers.find((s) => s.id === t.serverId)
let b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
if (!a || !b) continue
if (a.id === b.id) continue
const la = layerOfServer(a)
const lb = layerOfServer(b)
if (la == null || lb == null) continue
// EN→EN, JH→JH и т.п. на карте не показываем — только межуровневые рёбра.
if (la === lb) continue
// Строгий поток слева направо: HR → JH → EN.
// Если направление туннеля против иерархии — переворачиваем визуальное ребро.
let fromServer = a
let toServer = b
if (la > lb) {
fromServer = b
toServer = a
}
const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
+68
View File
@@ -0,0 +1,68 @@
import { requestJson } from "@/shared/api/http-client"
export type BackupItem = {
id: string
serverId: string
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual"
notes?: string
}
type CreateBackupResponse = {
created: BackupItem[]
failures: Array<{ serverId: string; error: string }>
}
export type CreateBackupJobResponse = {
jobId: string
status: "queued" | "running" | "done" | "failed"
total: number
completed: number
}
export type BackupJobStatusResponse = {
id: string
status: "queued" | "running" | "done" | "failed"
requestedAt: string
startedAt?: string
finishedAt?: string
total: number
completed: number
created: BackupItem[]
failures: Array<{ serverId: string; error: string }>
}
export async function listBackups(baseUrl: string): Promise<BackupItem[]> {
return requestJson<BackupItem[]>(baseUrl, "/api/backups")
}
export async function createBackups(
baseUrl: string,
payload: { serverIds: string[]; notes?: string },
): Promise<CreateBackupResponse> {
return requestJson<CreateBackupResponse>(baseUrl, "/api/backups/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function createBackupsAsync(
baseUrl: string,
payload: { serverIds: string[]; notes?: string },
): Promise<CreateBackupJobResponse> {
return requestJson<CreateBackupJobResponse>(baseUrl, "/api/backups/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function getBackupJob(baseUrl: string, jobId: string): Promise<BackupJobStatusResponse> {
return requestJson<BackupJobStatusResponse>(baseUrl, `/api/backups/jobs/${jobId}`)
}
export async function deleteBackup(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/backups/${id}`, { method: "DELETE" })
}