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
+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)