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:
+209
-11
@@ -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: правая (80–100%).
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user