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:
+162
-38
@@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import { backups as initialBackups, servers } from "@/lib/data"
|
import type { Backup, Server } from "@/lib/data"
|
||||||
import type { Backup } from "@/lib/data"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -18,6 +17,10 @@ import {
|
|||||||
FolderIcon,
|
FolderIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
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 ─────────────────────────────────────────────────────────
|
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -102,10 +105,16 @@ const defaultStorage = {
|
|||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
export default function BackupsPage() {
|
export default function BackupsPage() {
|
||||||
|
const { backendUrl } = useDataSource()
|
||||||
// ── state ──────────────────────────────────────────────────────────────
|
// ── state ──────────────────────────────────────────────────────────────
|
||||||
const [tab, setTab] = useState<PageTab>("history")
|
const [tab, setTab] = useState<PageTab>("history")
|
||||||
const [backupList, setBackupList] = useState<Backup[]>(initialBackups)
|
const [backupList, setBackupList] = useState<Backup[]>([])
|
||||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
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
|
// Schedule
|
||||||
const [schedule, setSchedule] = useState(defaultSchedule)
|
const [schedule, setSchedule] = useState(defaultSchedule)
|
||||||
@@ -119,7 +128,7 @@ export default function BackupsPage() {
|
|||||||
|
|
||||||
// Server selection (all enabled by default)
|
// Server selection (all enabled by default)
|
||||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(
|
const [selectedServers, setSelectedServers] = useState<Set<string>>(
|
||||||
new Set(servers.map((s) => s.id))
|
new Set()
|
||||||
)
|
)
|
||||||
function toggleServer(id: string) {
|
function toggleServer(id: string) {
|
||||||
setSelectedServers((prev) => {
|
setSelectedServers((prev) => {
|
||||||
@@ -147,31 +156,118 @@ export default function BackupsPage() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function handleManualBackup() {
|
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
|
||||||
const now = new Date()
|
const kb = Math.max(1, Math.round(b.sizeBytes / 1024))
|
||||||
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")}`
|
return {
|
||||||
const newBackups: Backup[] = Array.from(manualServers).map((sid, i) => {
|
id: b.id,
|
||||||
const sv = servers.find((s) => s.id === sid)!
|
server: b.serverName,
|
||||||
return {
|
filename: b.filename,
|
||||||
id: `b${Date.now()}${i}`,
|
size: `${kb} КБ`,
|
||||||
server: sv.name,
|
created: new Date(b.createdAt).toLocaleString("ru-RU"),
|
||||||
filename: `${sv.name}_${ts}_manual.rsc`,
|
kind: b.kind,
|
||||||
size: `${Math.floor(60 + Math.random()*80)} КБ`,
|
notes: b.notes ?? "",
|
||||||
created: "Только что",
|
}
|
||||||
kind: "manual",
|
}, [])
|
||||||
notes: manualNotes || "",
|
|
||||||
}
|
const loadLive = useCallback(async () => {
|
||||||
})
|
setLoading(true)
|
||||||
setBackupList((list) => [...newBackups, ...list])
|
setOpError(null)
|
||||||
setManualOpen(false)
|
try {
|
||||||
setManualServers(new Set())
|
const [serversRows, backupsRows] = await Promise.all([
|
||||||
setManualNotes("")
|
listServers(backendUrl),
|
||||||
setTab("history")
|
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
|
// Delete backup
|
||||||
function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
setBackupList((list) => list.filter((b) => b.id !== id))
|
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 ────────────────────────────────────────────────────────────
|
// ── derived ────────────────────────────────────────────────────────────
|
||||||
@@ -205,10 +301,22 @@ export default function BackupsPage() {
|
|||||||
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
||||||
actions={
|
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" />Снять со всех
|
<RefreshCwIcon className="size-4" />Снять со всех
|
||||||
</Button>
|
</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" />Новый бэкап
|
<PlusIcon className="size-4" />Новый бэкап
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -252,13 +360,23 @@ export default function BackupsPage() {
|
|||||||
<span className="h-3 w-px bg-border" />
|
<span className="h-3 w-px bg-border" />
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<ServerIcon className="size-3" />
|
<ServerIcon className="size-3" />
|
||||||
{selectedServers.size} из {servers.length} серверов
|
{selectedServers.size} из {liveServers.length} серверов
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => setTab("settings")}
|
<button onClick={() => setTab("settings")}
|
||||||
className="ml-auto text-xs text-primary hover:underline">
|
className="ml-auto text-xs text-primary hover:underline">
|
||||||
Изменить настройки →
|
Изменить настройки →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-1 border-b border-border">
|
<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-4 py-3 text-xs text-muted-foreground">{b.created}</td>
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<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" />
|
<DownloadIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
|
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
|
||||||
<RefreshCwIcon className="size-3.5" />
|
<RefreshCwIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
|
<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" />
|
<Trash2Icon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -548,7 +672,7 @@ export default function BackupsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
|
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<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>
|
className="text-xs text-primary hover:underline">Выбрать все</button>
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<button type="button" onClick={() => setSelectedServers(new Set())}
|
<button type="button" onClick={() => setSelectedServers(new Set())}
|
||||||
@@ -557,7 +681,7 @@ export default function BackupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
<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)
|
const checked = selectedServers.has(s.id)
|
||||||
return (
|
return (
|
||||||
<button key={s.id} type="button" onClick={() => toggleServer(s.id)}
|
<button key={s.id} type="button" onClick={() => toggleServer(s.id)}
|
||||||
@@ -586,7 +710,7 @@ export default function BackupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Выбрано {selectedServers.size} из {servers.length} серверов
|
Выбрано {selectedServers.size} из {liveServers.length} серверов
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -622,14 +746,14 @@ export default function BackupsPage() {
|
|||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<p className="text-sm font-medium">Выберите серверы</p>
|
<p className="text-sm font-medium">Выберите серверы</p>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
className="text-xs text-primary hover:underline">Все</button>
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<button type="button" onClick={() => setManualServers(new Set())}
|
<button type="button" onClick={() => setManualServers(new Set())}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{servers.map((s) => {
|
{liveServers.map((s) => {
|
||||||
const checked = manualServers.has(s.id)
|
const checked = manualServers.has(s.id)
|
||||||
return (
|
return (
|
||||||
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
<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">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||||
<Button className="flex-1"
|
<Button className="flex-1"
|
||||||
disabled={manualServers.size === 0}
|
disabled={manualServers.size === 0 || backupJobId !== null}
|
||||||
onClick={handleManualBackup}>
|
onClick={handleManualBackup}>
|
||||||
Снять бэкап ({manualServers.size})
|
Снять бэкап ({manualServers.size})
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -246,9 +246,12 @@ const STATUS_STYLE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_STYLE: Record<ServerType, { label: string; fill: string; r: number }> = {
|
const TYPE_STYLE: Record<ServerType, { label: string; fill: string; r: number }> = {
|
||||||
"jump-host": { label: "JH", fill: "#7c3aed", r: 36 },
|
// HR — визуальный корень топологии: крупнее и с более сильным акцентом.
|
||||||
"exit-node": { label: "EN", fill: "#0369a1", r: 36 },
|
"home-router": { label: "HR", fill: "#16a34a", r: 40 },
|
||||||
"home-router": { label: "HR", fill: "#16a34a", r: 30 },
|
// 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"]
|
const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"]
|
||||||
@@ -886,7 +889,10 @@ export default function NetworkMapPage() {
|
|||||||
})
|
})
|
||||||
}, [useLiveData, loadLive])
|
}, [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 wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers])
|
||||||
const srvResMap = useMemo(() => buildServerResourceMap(mapServers), [mapServers])
|
const srvResMap = useMemo(() => buildServerResourceMap(mapServers), [mapServers])
|
||||||
const homeRouters = useMemo(
|
const homeRouters = useMemo(
|
||||||
@@ -1099,7 +1105,16 @@ export default function NetworkMapPage() {
|
|||||||
return out
|
return out
|
||||||
}, [greEdges])
|
}, [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]))
|
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
|
||||||
|
|
||||||
// ── Refs ─────────────────────────────────────────────────────────────────────
|
// ── Refs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import probesRoutes from "./routes/probes.js"
|
|||||||
import schedulerRoutes from "./routes/scheduler.js"
|
import schedulerRoutes from "./routes/scheduler.js"
|
||||||
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
||||||
import alertsRoutes from "./routes/alerts.js"
|
import alertsRoutes from "./routes/alerts.js"
|
||||||
|
import backupsRoutes from "./routes/backups.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
|
|
||||||
// ── app factory ────────────────────────────────────────────────────────────────
|
// ── app factory ────────────────────────────────────────────────────────────────
|
||||||
@@ -60,6 +61,7 @@ await app.register(probesRoutes, { prefix: "/api" })
|
|||||||
await app.register(schedulerRoutes, { prefix: "/api" })
|
await app.register(schedulerRoutes, { prefix: "/api" })
|
||||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||||
await app.register(alertsRoutes, { prefix: "/api" })
|
await app.register(alertsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(backupsRoutes, { prefix: "/api" })
|
||||||
|
|
||||||
refreshScheduler()
|
refreshScheduler()
|
||||||
app.addHook("onClose", async () => {
|
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)
|
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 ─────────────────────────────────────────────────────────────────
|
// ── 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
@@ -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
@@ -42,11 +42,33 @@ const MARGIN = 72
|
|||||||
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
|
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
|
||||||
export const NETWORK_MAP_PIPELINE_Y = 300
|
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).
|
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
||||||
*/
|
*/
|
||||||
export const NETWORK_MAP_LAYOUT_REVISION = 5
|
export const NETWORK_MAP_LAYOUT_REVISION = 6
|
||||||
|
|
||||||
export interface WanJhEdge {
|
export interface WanJhEdge {
|
||||||
homeId: string
|
homeId: string
|
||||||
@@ -141,8 +163,91 @@ function resolveLaneCollisions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Детерминированные координаты узлов и «спутников» WAN под home-router. */
|
/** Детерминированные координаты узлов и «спутников» WAN под home-router.
|
||||||
export function computeNetworkMapLayout(servers: Server[]): {
|
*
|
||||||
|
* Важно: раскладка строго иерархическая по ролям:
|
||||||
|
* - 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 }>
|
nodePos: Record<string, { x: number; y: number }>
|
||||||
wanSatPos: 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 laneGap = Math.min(44, span * 0.04)
|
||||||
const laneW = (span - 2 * laneGap) / 3
|
const laneW = (span - 2 * laneGap) / 3
|
||||||
|
|
||||||
|
// Жёсткое позиционирование по иерархическим слоям:
|
||||||
|
// - HR: левая полоса (0–20%),
|
||||||
|
// - JH: центральная (40–60%),
|
||||||
|
// - EN: правая (80–100%).
|
||||||
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
|
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
|
||||||
const laneByType = new Map<ServerType, Server[]>()
|
const laneByType = new Map<ServerType, Server[]>()
|
||||||
const minNodeY = MARGIN + 70
|
const minNodeY = MARGIN + 70
|
||||||
@@ -193,30 +302,102 @@ export function computeNetworkMapLayout(servers: Server[]): {
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => `${a.site}|${a.name}|${a.id}`.localeCompare(`${b.site}|${b.name}|${b.id}`))
|
.sort((a, b) => `${a.site}|${a.name}|${a.id}`.localeCompare(`${b.site}|${b.name}|${b.id}`))
|
||||||
laneByType.set(type, group)
|
laneByType.set(type, group)
|
||||||
|
|
||||||
const xMin = xLane
|
const xMin = xLane
|
||||||
const xMax = xLane + laneW
|
const xMax = xLane + laneW
|
||||||
const laneCenterX = (xMin + xMax) / 2
|
const laneCenterX = (xMin + xMax) / 2
|
||||||
group.forEach((s, i) => {
|
|
||||||
|
group.forEach((s) => {
|
||||||
const baseline = siteY.get(s.site) ?? fallbackY
|
const baseline = siteY.get(s.site) ?? fallbackY
|
||||||
const siblings = group.filter((g) => g.site === s.site)
|
const siblings = group.filter((g) => g.site === s.site)
|
||||||
const sibIdx = siblings.findIndex((g) => g.id === s.id)
|
const sibIdx = siblings.findIndex((g) => g.id === s.id)
|
||||||
const spread = siblings.length <= 1 ? 0 : (sibIdx - (siblings.length - 1) / 2) * 34
|
const spread = siblings.length <= 1 ? 0 : (sibIdx - (siblings.length - 1) / 2) * 34
|
||||||
nodePos[s.id] = {
|
nodePos[s.id] = {
|
||||||
// Жёсткие колонки: Gateway слева, JH по центру, EN справа.
|
|
||||||
x: laneCenterX,
|
x: laneCenterX,
|
||||||
y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
|
y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
xLane += laneW + laneGap
|
xLane += laneW + laneGap
|
||||||
}
|
}
|
||||||
|
|
||||||
const homes = laneByType.get("home-router") ?? []
|
const homes = laneByType.get("home-router") ?? []
|
||||||
const jhs = laneByType.get("jump-host") ?? []
|
const jhs = laneByType.get("jump-host") ?? []
|
||||||
const exits = laneByType.get("exit-node") ?? []
|
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(homes.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
|
||||||
resolveLaneCollisions(jhs.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)
|
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
|
const hr = homes
|
||||||
/** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
|
/** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
|
||||||
const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2
|
const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2
|
||||||
@@ -227,9 +408,11 @@ export function computeNetworkMapLayout(servers: Server[]): {
|
|||||||
const n = wans.length
|
const n = wans.length
|
||||||
const satX = (base.x + jhColumnCenterX) / 2
|
const satX = (base.x + jhColumnCenterX) / 2
|
||||||
const rowY = base.y
|
const rowY = base.y
|
||||||
const maxV = H - 2 * MARGIN - 100
|
const maxV = H - 2 * MARGIN - 96
|
||||||
|
// Разносим WAN-аплинки по высоте заметнее для читаемости подписей/бейджей.
|
||||||
|
const preferredGap = 140
|
||||||
const vGap =
|
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 }[] = []
|
const positions: { x: number; y: number }[] = []
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const rawY = n === 1 ? rowY : rowY + (i - (n - 1) / 2) * vGap
|
const rawY = n === 1 ? rowY : rowY + (i - (n - 1) / 2) * vGap
|
||||||
@@ -583,10 +766,25 @@ export function buildGreMapEdges(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const t of tunnels) {
|
for (const t of tunnels) {
|
||||||
const fromServer = servers.find((s) => s.id === t.serverId)
|
let a = servers.find((s) => s.id === t.serverId)
|
||||||
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
|
let b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
|
||||||
if (!fromServer || !toServer) continue
|
if (!a || !b) continue
|
||||||
if (fromServer.id === toServer.id) 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 from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||||||
const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
|
const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user