chore: synchronize pending app/backend updates and repository hygiene
Includes current frontend and backend work in progress and removes generated artifacts from tracking to keep the repository clean for дальнейшая разработка. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -24,8 +24,11 @@ export function wanUplinkIpMatchesGreOuter(
|
||||
if (!a || !b) return false
|
||||
if (a === b) return true
|
||||
if (resolvedIpv4ByHost) {
|
||||
const r = resolvedIpv4ByHost.get(b.toLowerCase())
|
||||
if (r && a === normalizeGreEndpointAddr(r)) return true
|
||||
const rb = resolvedIpv4ByHost.get(b.toLowerCase())
|
||||
if (rb && a === normalizeGreEndpointAddr(rb)) return true
|
||||
const ra = resolvedIpv4ByHost.get(a.toLowerCase())
|
||||
if (ra && b === normalizeGreEndpointAddr(ra)) return true
|
||||
if (ra && rb && normalizeGreEndpointAddr(ra) === normalizeGreEndpointAddr(rb)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+180
-1
@@ -1,4 +1,5 @@
|
||||
import type { GreTunnel, Server } from "@/lib/data"
|
||||
import type { WanJhEdge } from "@/lib/network-map-layout"
|
||||
import {
|
||||
formatGreOuterForDisplay,
|
||||
normalizeGreEndpointAddr,
|
||||
@@ -24,6 +25,13 @@ export type GreTunnelProbeEdge = {
|
||||
toServer: Server
|
||||
}
|
||||
|
||||
/** Неориентированная пара узлов: один ключ для HR→MSK и MSK→HR (две строки GRE в БД). */
|
||||
export function canonicalGreServerPairKey(a: Pick<Server, "id">, b: Pick<Server, "id">): string {
|
||||
const x = String(a.id)
|
||||
const y = String(b.id)
|
||||
return x <= y ? `${x}\t${y}` : `${y}\t${x}`
|
||||
}
|
||||
|
||||
function normIface(i?: string | null): string {
|
||||
return (i ?? "").trim().toLowerCase()
|
||||
}
|
||||
@@ -122,6 +130,10 @@ function probesForServerPair(
|
||||
/**
|
||||
* Для всех GRE на карте назначает speed-пробы без повторного использования одной пробы
|
||||
* на два разных туннеля между одной и той же парой узлов (RT / MTS и т.п.).
|
||||
*
|
||||
* Пара узлов **неориентированная**: туннель в БД на HR→MSK и зеркальная запись MSK→HR
|
||||
* попадают в одну группу — иначе обе стороны независимо выбирают одни и те же пробы
|
||||
* и бейджи дублируются на разных линиях карты.
|
||||
*/
|
||||
export function assignSpeedProbesToGreTunnels(
|
||||
edges: GreTunnelProbeEdge[],
|
||||
@@ -132,7 +144,7 @@ export function assignSpeedProbesToGreTunnels(
|
||||
|
||||
const byPair = new Map<string, GreTunnelProbeEdge[]>()
|
||||
for (const e of edges) {
|
||||
const k = `${String(e.fromServer.id)}\t${String(e.toServer.id)}`
|
||||
const k = canonicalGreServerPairKey(e.fromServer, e.toServer)
|
||||
const arr = byPair.get(k) ?? []
|
||||
arr.push(e)
|
||||
byPair.set(k, arr)
|
||||
@@ -308,3 +320,170 @@ export function mergeGreMetricsWithSpeedProbe(
|
||||
sp?.lastRxAvgMbps != null ? Math.round(sp.lastRxAvgMbps) : fallback.ulMbps
|
||||
return { pingMs, dlMbps, ulMbps, hasSpeedMonitor }
|
||||
}
|
||||
|
||||
// ── WAN satellite → JumpHost: speed-пробы с src = Home Router и выбранным WAN-iface ──
|
||||
|
||||
export function wanJhEdgeMapKey(edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">): string {
|
||||
return `${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`
|
||||
}
|
||||
|
||||
function scoreSpeedProbeForWanJhEdge(
|
||||
p: GreSpeedProbeSnapshot,
|
||||
edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">,
|
||||
home: Server,
|
||||
): number {
|
||||
if (String(p.srcServerId) !== edge.homeId || String(p.dstServerId) !== edge.jhId) return -1
|
||||
if (p.enabled === false) return -1
|
||||
const wanIface = normIface(home.wanUplinks?.[edge.wanIdx]?.iface)
|
||||
const si = normIface(p.srcInterface)
|
||||
if (si && wanIface && si !== wanIface) return -1
|
||||
let s = 0
|
||||
if (si && wanIface && si === wanIface) s += 100
|
||||
else if (!si && wanIface) s += 35
|
||||
else if (!wanIface && si) s += 15
|
||||
else if (!si && !wanIface) s += 5
|
||||
if (p.lastPingRttMs != null || p.lastTxAvgMbps != null || p.lastRxAvgMbps != null) s += 6
|
||||
return s
|
||||
}
|
||||
|
||||
function probesHomeToJh(allProbes: GreSpeedProbeSnapshot[], homeId: string, jhId: string): GreSpeedProbeSnapshot[] {
|
||||
return allProbes.filter((p) => {
|
||||
if (p.enabled === false) return false
|
||||
return String(p.srcServerId) === homeId && String(p.dstServerId) === jhId
|
||||
})
|
||||
}
|
||||
|
||||
function assignSpeedProbesWithinWanJhGroup(
|
||||
edges: WanJhEdge[],
|
||||
home: Server,
|
||||
allProbes: GreSpeedProbeSnapshot[],
|
||||
): Map<string, GreSpeedProbeSnapshot | undefined> {
|
||||
const out = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||||
if (edges.length === 0) return out
|
||||
const E = [...edges].sort((a, b) => a.wanIdx - b.wanIdx || a.jhId.localeCompare(b.jhId))
|
||||
const pairProbes = probesHomeToJh(allProbes, E[0].homeId, E[0].jhId)
|
||||
|
||||
if (E.length === 1) {
|
||||
let best: GreSpeedProbeSnapshot | undefined
|
||||
let bestS = -1
|
||||
for (const p of pairProbes) {
|
||||
const sc = scoreSpeedProbeForWanJhEdge(p, E[0], home)
|
||||
if (sc > bestS) {
|
||||
bestS = sc
|
||||
best = p
|
||||
}
|
||||
}
|
||||
out.set(wanJhEdgeMapKey(E[0]), best != null && bestS >= 0 ? best : undefined)
|
||||
return out
|
||||
}
|
||||
|
||||
const n = E.length
|
||||
const m = pairProbes.length
|
||||
if (m === 0) {
|
||||
for (const e of E) out.set(wanJhEdgeMapKey(e), undefined)
|
||||
return out
|
||||
}
|
||||
|
||||
let bestSum = -Infinity
|
||||
let bestAssign: Map<string, GreSpeedProbeSnapshot> | null = null
|
||||
|
||||
function dfs(i: number, used: Set<string>, cur: Map<string, GreSpeedProbeSnapshot>) {
|
||||
if (i === n) {
|
||||
let sum = 0
|
||||
for (const e of E) {
|
||||
const p = cur.get(wanJhEdgeMapKey(e))
|
||||
if (!p) return
|
||||
sum += scoreSpeedProbeForWanJhEdge(p, e, home)
|
||||
}
|
||||
if (sum > bestSum) {
|
||||
bestSum = sum
|
||||
bestAssign = new Map(cur)
|
||||
}
|
||||
return
|
||||
}
|
||||
const edge = E[i]!
|
||||
const slotKey = wanJhEdgeMapKey(edge)
|
||||
for (const p of pairProbes) {
|
||||
if (used.has(p.id)) continue
|
||||
if (scoreSpeedProbeForWanJhEdge(p, edge, home) < 0) continue
|
||||
used.add(p.id)
|
||||
cur.set(slotKey, p)
|
||||
dfs(i + 1, used, cur)
|
||||
cur.delete(slotKey)
|
||||
used.delete(p.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (n <= 7 && n <= m) dfs(0, new Set(), new Map())
|
||||
|
||||
const inject = bestAssign as Map<string, GreSpeedProbeSnapshot> | null
|
||||
if (inject !== null && inject.size === n) {
|
||||
for (const ed of E) out.set(wanJhEdgeMapKey(ed), inject.get(wanJhEdgeMapKey(ed)))
|
||||
return out
|
||||
}
|
||||
|
||||
const used = new Set<string>()
|
||||
const remaining = new Set(E.map((e) => wanJhEdgeMapKey(e)))
|
||||
while (remaining.size > 0) {
|
||||
let pickEdge: WanJhEdge | undefined
|
||||
let pickProbe: GreSpeedProbeSnapshot | undefined
|
||||
let pickScore = -Infinity
|
||||
for (const key of remaining) {
|
||||
const e = E.find((x) => wanJhEdgeMapKey(x) === key)!
|
||||
for (const p of pairProbes) {
|
||||
if (used.has(p.id)) continue
|
||||
const sc = scoreSpeedProbeForWanJhEdge(p, e, home)
|
||||
if (sc < 0) continue
|
||||
if (
|
||||
sc > pickScore ||
|
||||
(sc === pickScore && pickProbe != null && p.id.localeCompare(pickProbe.id) < 0)
|
||||
) {
|
||||
pickScore = sc
|
||||
pickEdge = e
|
||||
pickProbe = p
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pickEdge || !pickProbe) {
|
||||
for (const key of remaining) out.set(key, undefined)
|
||||
break
|
||||
}
|
||||
out.set(wanJhEdgeMapKey(pickEdge), pickProbe)
|
||||
used.add(pickProbe.id)
|
||||
remaining.delete(wanJhEdgeMapKey(pickEdge))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Назначает speed-пробы сегментам WAN→JH (Home → спутник → JH): по паре серверов и iface источника,
|
||||
* без повторного использования одной пробы на два WAN одного home→jh (RT / MTS).
|
||||
*/
|
||||
export function assignSpeedProbesToWanJhEdges(
|
||||
edges: WanJhEdge[],
|
||||
servers: Server[],
|
||||
probes: GreSpeedProbeSnapshot[],
|
||||
): ReadonlyMap<string, GreSpeedProbeSnapshot | undefined> {
|
||||
const result = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||||
const homeById = new Map(servers.filter((s) => s.type === "home-router").map((s) => [s.id, s] as const))
|
||||
|
||||
const byPair = new Map<string, WanJhEdge[]>()
|
||||
for (const e of edges) {
|
||||
const k = `${e.homeId}\t${e.jhId}`
|
||||
const arr = byPair.get(k) ?? []
|
||||
arr.push(e)
|
||||
byPair.set(k, arr)
|
||||
}
|
||||
|
||||
for (const group of byPair.values()) {
|
||||
const home = homeById.get(group[0].homeId)
|
||||
if (!home) {
|
||||
for (const e of group) result.set(wanJhEdgeMapKey(e), undefined)
|
||||
continue
|
||||
}
|
||||
const assigned = assignSpeedProbesWithinWanJhGroup(group, home, probes)
|
||||
for (const [k, sp] of assigned) result.set(k, sp)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
+172
-11
@@ -46,7 +46,7 @@ export const NETWORK_MAP_PIPELINE_Y = 300
|
||||
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
|
||||
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
||||
*/
|
||||
export const NETWORK_MAP_LAYOUT_REVISION = 3
|
||||
export const NETWORK_MAP_LAYOUT_REVISION = 5
|
||||
|
||||
export interface WanJhEdge {
|
||||
homeId: string
|
||||
@@ -106,6 +106,41 @@ function spreadInSegment(count: number, index: number, xMin: number, xMax: numbe
|
||||
return xMin + (index / (count - 1)) * (xMax - xMin)
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
function resolveLaneCollisions(
|
||||
ids: string[],
|
||||
nodePos: Record<string, { x: number; y: number }>,
|
||||
minY: number,
|
||||
maxY: number,
|
||||
minGap: number,
|
||||
): void {
|
||||
if (ids.length <= 1) {
|
||||
if (ids.length === 1) {
|
||||
const id = ids[0]
|
||||
const p = nodePos[id]
|
||||
if (p) p.y = clamp(p.y, minY, maxY)
|
||||
}
|
||||
return
|
||||
}
|
||||
const sorted = ids
|
||||
.map((id) => ({ id, y: nodePos[id]?.y ?? (minY + maxY) / 2 }))
|
||||
.sort((a, b) => a.y - b.y)
|
||||
const n = sorted.length
|
||||
const maxSpan = maxY - minY
|
||||
const gap = Math.min(minGap, maxSpan / (n - 1))
|
||||
const center = sorted.reduce((acc, s) => acc + s.y, 0) / n
|
||||
const span = gap * (n - 1)
|
||||
const start = clamp(center - span / 2, minY, maxY - span)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = nodePos[sorted[i].id]
|
||||
if (!p) continue
|
||||
p.y = start + i * gap
|
||||
}
|
||||
}
|
||||
|
||||
/** Детерминированные координаты узлов и «спутников» WAN под home-router. */
|
||||
export function computeNetworkMapLayout(servers: Server[]): {
|
||||
nodePos: Record<string, { x: number; y: number }>
|
||||
@@ -119,21 +154,70 @@ export function computeNetworkMapLayout(servers: Server[]): {
|
||||
const laneW = (span - 2 * laneGap) / 3
|
||||
|
||||
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
|
||||
const laneByType = new Map<ServerType, Server[]>()
|
||||
const minNodeY = MARGIN + 70
|
||||
const maxNodeY = H - 72
|
||||
const laneMinGap = 110
|
||||
const typeYOffset: Record<ServerType, number> = {
|
||||
"home-router": -14,
|
||||
"jump-host": 0,
|
||||
"exit-node": 14,
|
||||
}
|
||||
const siteWeight = (s: Server): number => {
|
||||
const typeBoost = s.type === "jump-host" ? 3 : s.type === "home-router" ? 2 : 1
|
||||
const statusBoost = s.status === "online" ? 2 : s.status === "degraded" ? 1 : 0
|
||||
return typeBoost + statusBoost
|
||||
}
|
||||
const siteNames = [
|
||||
...new Set(
|
||||
servers
|
||||
.map((s) => s.site?.trim())
|
||||
.filter((v): v is string => Boolean(v)),
|
||||
),
|
||||
]
|
||||
siteNames.sort((a, b) => {
|
||||
const wa = servers.filter((s) => s.site === a).reduce((acc, s) => acc + siteWeight(s), 0)
|
||||
const wb = servers.filter((s) => s.site === b).reduce((acc, s) => acc + siteWeight(s), 0)
|
||||
if (wb !== wa) return wb - wa
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const siteY = new Map<string, number>()
|
||||
siteNames.forEach((site, i) => {
|
||||
siteY.set(site, spreadInSegment(siteNames.length, i, minNodeY, maxNodeY))
|
||||
})
|
||||
const fallbackY = (minNodeY + maxNodeY) / 2
|
||||
let xLane = MARGIN
|
||||
for (const type of laneOrder) {
|
||||
const group = servers.filter((s) => s.type === type)
|
||||
const group = servers
|
||||
.filter((s) => s.type === type)
|
||||
.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) => {
|
||||
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] = {
|
||||
x: spreadInSegment(group.length, i, xMin, xMax),
|
||||
y: NETWORK_MAP_PIPELINE_Y,
|
||||
// Жёсткие колонки: Gateway слева, JH по центру, EN справа.
|
||||
x: laneCenterX,
|
||||
y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
|
||||
}
|
||||
})
|
||||
xLane += laneW + laneGap
|
||||
}
|
||||
|
||||
const hr = servers.filter((s) => s.type === "home-router")
|
||||
const homes = laneByType.get("home-router") ?? []
|
||||
const jhs = laneByType.get("jump-host") ?? []
|
||||
const exits = laneByType.get("exit-node") ?? []
|
||||
resolveLaneCollisions(homes.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
|
||||
resolveLaneCollisions(jhs.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
|
||||
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
|
||||
|
||||
@@ -142,7 +226,7 @@ export function computeNetworkMapLayout(servers: Server[]): {
|
||||
const base = nodePos[r.id] ?? { x: W / 2, y: NETWORK_MAP_PIPELINE_Y }
|
||||
const n = wans.length
|
||||
const satX = (base.x + jhColumnCenterX) / 2
|
||||
const rowY = NETWORK_MAP_PIPELINE_Y
|
||||
const rowY = base.y
|
||||
const maxV = H - 2 * MARGIN - 100
|
||||
const vGap =
|
||||
n <= 1 ? 0 : Math.min(56, maxV / Math.max(1, n - 1))
|
||||
@@ -163,8 +247,9 @@ export function computeNetworkMapLayout(servers: Server[]): {
|
||||
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
||||
*/
|
||||
function legPing(home: Server, jh: Server): number | null {
|
||||
const hOk = home.enabled && home.status !== "offline"
|
||||
const jOk = jh.enabled && jh.status !== "offline"
|
||||
/** Для линий на карте достаточно «живого» статуса; `enabled` не скрывает топологию. */
|
||||
const hOk = home.status !== "offline"
|
||||
const jOk = jh.status !== "offline"
|
||||
if (!hOk || !jOk) return null
|
||||
if (home.latency == null || jh.latency == null) return null
|
||||
return Math.min(995, Math.round(home.latency + jh.latency))
|
||||
@@ -172,8 +257,8 @@ function legPing(home: Server, jh: Server): number | null {
|
||||
|
||||
/** Рёбра WAN→JumpHost для отрисовки (`latency` с бекенда — те же объекты, что и в каталоге серверов). */
|
||||
export function buildWanJhEdges(servers: Server[]): WanJhEdge[] {
|
||||
const homes = servers.filter((s) => s.type === "home-router" && s.enabled)
|
||||
const jhs = servers.filter((s) => s.type === "jump-host" && s.enabled && s.status !== "offline")
|
||||
const homes = servers.filter((s) => s.type === "home-router")
|
||||
const jhs = servers.filter((s) => s.type === "jump-host" && s.status !== "offline")
|
||||
if (homes.length === 0 || jhs.length === 0) return []
|
||||
|
||||
const edges: WanJhEdge[] = []
|
||||
@@ -307,6 +392,41 @@ function hostWithoutPort(host: string): string {
|
||||
return h
|
||||
}
|
||||
|
||||
function normalizeIpNoMask(raw: string | null | undefined): string {
|
||||
if (!raw) return ""
|
||||
return normalizeGreEndpoint(raw.split("/")[0] ?? "")
|
||||
}
|
||||
|
||||
function canonicalIpPairKey(aRaw: string, bRaw: string): string | null {
|
||||
const a = normalizeGreEndpoint(aRaw)
|
||||
const b = normalizeGreEndpoint(bRaw)
|
||||
if (!a || !b || a === "0.0.0.0" || b === "0.0.0.0") return null
|
||||
const p1 = a <= b ? a : b
|
||||
const p2 = a <= b ? b : a
|
||||
return `${p1}|${p2}`
|
||||
}
|
||||
|
||||
function canonicalOuterPairKey(
|
||||
t: GreTunnel,
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): string | null {
|
||||
const la = greOuterComparableIps(t.localAddress, resolvedIpv4ByHost)
|
||||
const ra = greOuterComparableIps(t.remoteAddress, resolvedIpv4ByHost)
|
||||
const left = [...new Set(la.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
|
||||
const right = [...new Set(ra.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
|
||||
if (left.length === 0 || right.length === 0) return null
|
||||
let best: string | null = null
|
||||
for (const l of left) {
|
||||
for (const r of right) {
|
||||
const p1 = l <= r ? l : r
|
||||
const p2 = l <= r ? r : l
|
||||
const k = `${p1}|${p2}`
|
||||
if (best == null || k < best) best = k
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти сервер в каталоге по GRE remote-address: совпадение с management host или WAN IP.
|
||||
* Для FQDN в remote-address — передайте карту DNS из `/api/network/resolve-hosts`.
|
||||
@@ -432,6 +552,36 @@ export function buildGreMapEdges(
|
||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||
): GreMapEdge[] {
|
||||
const out: GreMapEdge[] = []
|
||||
const seenByLink = new Map<string, number>()
|
||||
|
||||
const statusRank: Record<GreTunnel["status"], number> = {
|
||||
up: 3,
|
||||
degraded: 2,
|
||||
down: 1,
|
||||
}
|
||||
|
||||
function tunnelPriority(t: GreTunnel): number {
|
||||
return (t.enabled ? 100 : 0) + (statusRank[t.status] ?? 0)
|
||||
}
|
||||
|
||||
function canonicalLinkKey(t: GreTunnel, a: Server, b: Server): string {
|
||||
const s1 = a.id <= b.id ? a.id : b.id
|
||||
const s2 = a.id <= b.id ? b.id : a.id
|
||||
// 1) Предпочитаем inner-пару: у directional A→B/B→A она одинакова по /30.
|
||||
const inner = canonicalIpPairKey(
|
||||
normalizeIpNoMask(t.localInnerIp),
|
||||
normalizeIpNoMask(t.remoteInnerIp),
|
||||
)
|
||||
if (inner) return `${s1}|${s2}|inner:${inner}`
|
||||
|
||||
// 2) Иначе outer-пара с учетом DNS/FQDN→IPv4 сопоставления.
|
||||
const outer = canonicalOuterPairKey(t, resolvedIpv4ByHost)
|
||||
if (outer) return `${s1}|${s2}|outer:${outer}`
|
||||
|
||||
// 3) Крайний случай: чтобы не схлопнуть потенциально разные линki.
|
||||
return `${s1}|${s2}|id:${t.id}`
|
||||
}
|
||||
|
||||
for (const t of tunnels) {
|
||||
const fromServer = servers.find((s) => s.id === t.serverId)
|
||||
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
|
||||
@@ -440,7 +590,18 @@ export function buildGreMapEdges(
|
||||
|
||||
const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||||
const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||||
out.push({ tunnel: t, from, to, fromServer, toServer })
|
||||
const edge: GreMapEdge = { tunnel: t, from, to, fromServer, toServer }
|
||||
const key = canonicalLinkKey(t, fromServer, toServer)
|
||||
const prevIdx = seenByLink.get(key)
|
||||
if (prevIdx == null) {
|
||||
seenByLink.set(key, out.length)
|
||||
out.push(edge)
|
||||
continue
|
||||
}
|
||||
const prev = out[prevIdx]
|
||||
if (tunnelPriority(t) > tunnelPriority(prev.tunnel)) {
|
||||
out[prevIdx] = edge
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Порог RTT (мс) для визуала «деградации» на `/uptime` — должен совпадать с
|
||||
* `PING_PROBE_WARN_RTT_MS` в `backend/src/constants/ping-probe.ts` (логика `parsePing`).
|
||||
*/
|
||||
export const PING_PROBE_WARN_RTT_MS = 120
|
||||
+223
-13
@@ -3,7 +3,7 @@
|
||||
* (см. блок `#route-ai` в `/settings` и страницу `/route-optimizer`).
|
||||
*/
|
||||
|
||||
import type { FilterRule } from "@/lib/data"
|
||||
import { servers, type FilterRule } from "@/lib/data"
|
||||
|
||||
// ── Совместимо с app/(main)/route-optimizer/page.tsx ─────────────────────────
|
||||
|
||||
@@ -130,6 +130,15 @@ export interface OptimizerApiServer {
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null
|
||||
model: string | null
|
||||
wanUplinks?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
isp: string
|
||||
iface: string
|
||||
ip: string
|
||||
maxDl: number
|
||||
maxUl: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface FiltersRulesetRow {
|
||||
@@ -137,10 +146,29 @@ export interface FiltersRulesetRow {
|
||||
rules: FilterRule[]
|
||||
}
|
||||
|
||||
function calcScore(pingMs: number, dlMbps: number, ulMbps: number, pw: number) {
|
||||
/** Снимок speed-probe из /api/uptime/speed-probes (live-источник ping/speed по интерфейсам). */
|
||||
export interface RouteOptimizerSpeedProbe {
|
||||
id: string
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface: string
|
||||
dstInterface: string
|
||||
enabled: boolean
|
||||
lastPingRttMs: number | null
|
||||
lastPingLossPct: number | null
|
||||
lastTxAvgMbps: number | null
|
||||
lastRxAvgMbps: number | null
|
||||
lastPingAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый расчёт score для Route AI (используется в /route-optimizer и связанных оптимизаторах).
|
||||
* pingWeight = вес ping в процентах (0..100), оставшийся вес идёт в speed.
|
||||
*/
|
||||
export function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) {
|
||||
const pingScore = Math.max(0, 100 - pingMs * 0.6)
|
||||
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
|
||||
const w = pw / 100
|
||||
const w = pingWeight / 100
|
||||
return Math.round(w * pingScore + (1 - w) * speedScore)
|
||||
}
|
||||
|
||||
@@ -160,6 +188,45 @@ function syntheticWans(home: OptimizerApiServer): WanUplink[] {
|
||||
}]
|
||||
}
|
||||
|
||||
function normalizeApiWanUplinks(list: OptimizerApiServer["wanUplinks"]): WanUplink[] {
|
||||
const src = Array.isArray(list) ? list : []
|
||||
const out = src
|
||||
.map((w, idx) => ({
|
||||
id: String(w.id || `api-w-${idx + 1}`),
|
||||
name: String(w.name || `WAN${idx + 1}`),
|
||||
isp: String(w.isp || "—"),
|
||||
iface: String(w.iface || "").trim(),
|
||||
ip: String(w.ip || ""),
|
||||
maxDl: Math.max(1, Math.round(Number(w.maxDl) || 1000)),
|
||||
maxUl: Math.max(1, Math.round(Number(w.maxUl) || 1000)),
|
||||
}))
|
||||
.filter((w) => w.iface.length > 0)
|
||||
return out
|
||||
}
|
||||
|
||||
function catalogWansForHome(homeId: string, homeHost?: string, homeName?: string): WanUplink[] | null {
|
||||
const hostNorm = (homeHost ?? "").trim().toLowerCase()
|
||||
const nameNorm = (homeName ?? "").trim().toLowerCase()
|
||||
const row = servers.find((s) => {
|
||||
if (s.type !== "home-router") return false
|
||||
if (String(s.id) === homeId) return true
|
||||
if (hostNorm && String(s.host ?? "").trim().toLowerCase() === hostNorm) return true
|
||||
if (nameNorm && String(s.name ?? "").trim().toLowerCase() === nameNorm) return true
|
||||
return false
|
||||
})
|
||||
const list = row?.wanUplinks ?? []
|
||||
if (!list.length) return null
|
||||
return list.map((w, idx) => ({
|
||||
id: w.id || `w-${homeId}-${idx + 1}`,
|
||||
name: w.name || `WAN${idx + 1}`,
|
||||
isp: w.isp || "—",
|
||||
iface: w.iface || "",
|
||||
ip: w.ip || "",
|
||||
maxDl: Math.max(1, Math.round(Number(w.maxDl) || 1000)),
|
||||
maxUl: Math.max(1, Math.round(Number(w.maxUl) || 1000)),
|
||||
}))
|
||||
}
|
||||
|
||||
function legPing(
|
||||
a: number | null,
|
||||
b: number | null,
|
||||
@@ -202,7 +269,10 @@ export function mapApiServersToTopology(rows: OptimizerApiServer[]): {
|
||||
country: s.country || "UN",
|
||||
model: s.model ?? "—",
|
||||
ip: s.host,
|
||||
wans: syntheticWans(s),
|
||||
wans:
|
||||
normalizeApiWanUplinks(s.wanUplinks).length > 0
|
||||
? normalizeApiWanUplinks(s.wanUplinks)
|
||||
: (catalogWansForHome(String(s.id), s.host, s.name) ?? syntheticWans(s)),
|
||||
}))
|
||||
|
||||
const jumpHosts: JumpHost[] = rows
|
||||
@@ -236,6 +306,7 @@ export function buildLiveOptimizerData(
|
||||
rows: OptimizerApiServer[],
|
||||
rulesets: FiltersRulesetRow[] | null,
|
||||
settings: OptimizerSettings,
|
||||
speedProbes: RouteOptimizerSpeedProbe[] = [],
|
||||
): OptimizerData {
|
||||
const { homes, jumpHosts, exitNodes, byId } = mapApiServersToTopology(rows)
|
||||
const pw = settings.pingWeight
|
||||
@@ -255,13 +326,142 @@ export function buildLiveOptimizerData(
|
||||
const homeLat = homeRow?.latency ?? null
|
||||
|
||||
const wanJhLegs: WanJhLeg[] = []
|
||||
for (const wan of home.wans) {
|
||||
for (const jh of jumpHosts) {
|
||||
const probesForHome = speedProbes
|
||||
.filter((p) => p.enabled !== false && p.srcServerId === home.id)
|
||||
|
||||
function scoreProbeForWanIface(p: RouteOptimizerSpeedProbe, wanIface: string): number {
|
||||
const want = wanIface.trim().toLowerCase()
|
||||
const got = String(p.srcInterface ?? "").trim().toLowerCase()
|
||||
if (got && want && got !== want) return -1
|
||||
const ifaceScore =
|
||||
got && want && got === want ? 100
|
||||
: (!got && want) ? 35
|
||||
: (!want && got) ? 15
|
||||
: 5
|
||||
const freshness =
|
||||
p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0
|
||||
return ifaceScore
|
||||
+ (p.lastPingRttMs != null ? 20 : 0)
|
||||
+ (p.lastTxAvgMbps != null ? 10 : 0)
|
||||
+ (p.lastRxAvgMbps != null ? 10 : 0)
|
||||
+ freshness
|
||||
}
|
||||
|
||||
function assignProbesForWanJh(jhId: string): Map<string, RouteOptimizerSpeedProbe | undefined> {
|
||||
const out = new Map<string, RouteOptimizerSpeedProbe | undefined>()
|
||||
const pool = probesForHome.filter((p) => p.dstServerId === jhId)
|
||||
if (!pool.length) {
|
||||
for (const wan of home.wans) out.set(wan.id, undefined)
|
||||
return out
|
||||
}
|
||||
|
||||
let bestSum = -Infinity
|
||||
let bestAssign: Map<string, RouteOptimizerSpeedProbe> | null = null
|
||||
const wanList = [...home.wans]
|
||||
|
||||
function dfs(i: number, used: Set<string>, cur: Map<string, RouteOptimizerSpeedProbe>, sum: number) {
|
||||
if (i === wanList.length) {
|
||||
if (sum > bestSum) {
|
||||
bestSum = sum
|
||||
bestAssign = new Map(cur)
|
||||
}
|
||||
return
|
||||
}
|
||||
const wan = wanList[i]!
|
||||
let picked = false
|
||||
for (const p of pool) {
|
||||
if (used.has(p.id)) continue
|
||||
const sc = scoreProbeForWanIface(p, wan.iface)
|
||||
if (sc < 0) continue
|
||||
picked = true
|
||||
used.add(p.id)
|
||||
cur.set(wan.id, p)
|
||||
dfs(i + 1, used, cur, sum + sc)
|
||||
cur.delete(wan.id)
|
||||
used.delete(p.id)
|
||||
}
|
||||
if (!picked) dfs(i + 1, used, cur, sum)
|
||||
}
|
||||
|
||||
if (wanList.length <= 7 && wanList.length <= pool.length) {
|
||||
dfs(0, new Set(), new Map(), 0)
|
||||
}
|
||||
|
||||
if (bestAssign) {
|
||||
for (const wan of home.wans) out.set(wan.id, bestAssign.get(wan.id))
|
||||
return out
|
||||
}
|
||||
|
||||
const used = new Set<string>()
|
||||
for (const wan of home.wans) {
|
||||
let best: RouteOptimizerSpeedProbe | undefined
|
||||
let bestScore = -1
|
||||
for (const p of pool) {
|
||||
if (used.has(p.id)) continue
|
||||
const sc = scoreProbeForWanIface(p, wan.iface)
|
||||
if (sc > bestScore) {
|
||||
bestScore = sc
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if (best && bestScore >= 0) {
|
||||
out.set(wan.id, best)
|
||||
used.add(best.id)
|
||||
} else {
|
||||
out.set(wan.id, undefined)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function pickProbeForJhExit(jhId: string, exitId: string): RouteOptimizerSpeedProbe | undefined {
|
||||
const pool = speedProbes.filter((p) => {
|
||||
if (p.enabled === false) return false
|
||||
const a = p.srcServerId === jhId && p.dstServerId === exitId
|
||||
const b = p.srcServerId === exitId && p.dstServerId === jhId
|
||||
return a || b
|
||||
})
|
||||
if (!pool.length) return undefined
|
||||
let best: RouteOptimizerSpeedProbe | undefined
|
||||
let bestScore = -1
|
||||
for (const p of pool) {
|
||||
const forward = p.srcServerId === jhId && p.dstServerId === exitId
|
||||
const freshness =
|
||||
p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0
|
||||
const score =
|
||||
(forward ? 5 : 0)
|
||||
+ (p.lastPingRttMs != null ? 25 : 0)
|
||||
+ (p.lastTxAvgMbps != null ? 12 : 0)
|
||||
+ (p.lastRxAvgMbps != null ? 12 : 0)
|
||||
+ freshness
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = p
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
for (const jh of jumpHosts) {
|
||||
const assignedByWanId = assignProbesForWanJh(jh.id)
|
||||
for (const wan of home.wans) {
|
||||
const jhRow = byId.get(jh.id)
|
||||
const jhOnline = jhRow?.status === "online"
|
||||
const ping = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline)
|
||||
const { dl, ul } = legBandwidthMbps(ping, wan.maxDl, wan.maxUl)
|
||||
const loss = !homeOnline || !jhOnline ? 100 : 0
|
||||
const probe = assignedByWanId.get(wan.id)
|
||||
const modelPing = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline)
|
||||
const ping = probe?.lastPingRttMs != null
|
||||
? Math.max(1, Math.round(probe.lastPingRttMs))
|
||||
: modelPing
|
||||
const modelBw = legBandwidthMbps(ping, wan.maxDl, wan.maxUl)
|
||||
const dl = probe?.lastTxAvgMbps != null
|
||||
? Math.max(1, Math.min(wan.maxDl, Math.round(probe.lastTxAvgMbps)))
|
||||
: modelBw.dl
|
||||
const ul = probe?.lastRxAvgMbps != null
|
||||
? Math.max(1, Math.min(wan.maxUl, Math.round(probe.lastRxAvgMbps)))
|
||||
: modelBw.ul
|
||||
const loss = probe?.lastPingLossPct != null
|
||||
? Math.max(0, Math.min(100, Math.round(probe.lastPingLossPct)))
|
||||
: (!homeOnline || !jhOnline ? 100 : 0)
|
||||
wanJhLegs.push({
|
||||
wanId: wan.id,
|
||||
jhId: jh.id,
|
||||
@@ -269,7 +469,7 @@ export function buildLiveOptimizerData(
|
||||
dlMbps: dl,
|
||||
ulMbps: ul,
|
||||
loss,
|
||||
score: calcScore(ping, dl, ul, pw),
|
||||
score: calcRouteScore(ping, dl, ul, pw),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -280,8 +480,18 @@ export function buildLiveOptimizerData(
|
||||
const jhRow = byId.get(jh.id)
|
||||
const exRow = byId.get(ex.id)
|
||||
const ok = jhRow?.status === "online" && exRow?.status === "online"
|
||||
const ping = legPing(jhRow?.latency ?? null, exRow?.latency ?? null, !!ok, !!ok)
|
||||
const { dl, ul } = legBandwidthMbps(ping, 1000, 1000)
|
||||
const probe = pickProbeForJhExit(jh.id, ex.id)
|
||||
const modelPing = legPing(jhRow?.latency ?? null, exRow?.latency ?? null, !!ok, !!ok)
|
||||
const ping = probe?.lastPingRttMs != null
|
||||
? Math.max(1, Math.round(probe.lastPingRttMs))
|
||||
: modelPing
|
||||
const modelBw = legBandwidthMbps(ping, 1000, 1000)
|
||||
const dl = probe?.lastTxAvgMbps != null
|
||||
? Math.max(1, Math.round(probe.lastTxAvgMbps))
|
||||
: modelBw.dl
|
||||
const ul = probe?.lastRxAvgMbps != null
|
||||
? Math.max(1, Math.round(probe.lastRxAvgMbps))
|
||||
: modelBw.ul
|
||||
jhExMap.set(`${jh.id}::${ex.id}`, {
|
||||
jhId: jh.id,
|
||||
exitId: ex.id,
|
||||
@@ -302,7 +512,7 @@ export function buildLiveOptimizerData(
|
||||
const totalPing = hw.pingMs + je.pingMs
|
||||
const dl = Math.min(hw.dlMbps, je.dlMbps)
|
||||
const ul = Math.min(hw.ulMbps, je.ulMbps)
|
||||
const score = calcScore(totalPing, dl, ul, pw)
|
||||
const score = calcRouteScore(totalPing, dl, ul, pw)
|
||||
fullRoutes.push({
|
||||
id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`,
|
||||
homeId: home.id,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Снимок прогона планировщика (JSON из `scheduler_runs.result_json`).
|
||||
* Дублирует контракт `backend/src/types/scheduler-run-snapshot.ts` для UI.
|
||||
*/
|
||||
|
||||
export interface ServerRestPingSnapshot {
|
||||
serverId: number
|
||||
name: string
|
||||
host: string
|
||||
ok: boolean
|
||||
latencyMs?: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ServersRestPingRunSnapshot {
|
||||
v: number
|
||||
job: "servers_rest_ping"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
fatalError?: string
|
||||
servers: ServerRestPingSnapshot[]
|
||||
}
|
||||
|
||||
export type AlertEngineRuleDiagSnapshot = {
|
||||
ruleId: string
|
||||
inGroup: boolean
|
||||
evalHit: boolean
|
||||
stabilityOk: boolean
|
||||
cooldownOk: boolean
|
||||
telegramOk: boolean
|
||||
blocked?:
|
||||
| "no_hit"
|
||||
| "stability"
|
||||
| "cooldown"
|
||||
| "no_telegram"
|
||||
| "dedupe_positive"
|
||||
| "in_group"
|
||||
}
|
||||
|
||||
/** Сводка прогона движка оповещений (JSON из `scheduler_runs` для job `alert_engine`). */
|
||||
export interface AlertEngineRunSnapshot {
|
||||
v: number
|
||||
job: "alert_engine"
|
||||
sampledAt: string
|
||||
rulesChecked: number
|
||||
standaloneFires: number
|
||||
groupFires: number
|
||||
skippedNoTelegram: boolean
|
||||
errors?: string[]
|
||||
ruleDiag?: AlertEngineRuleDiagSnapshot[]
|
||||
}
|
||||
|
||||
export interface GreBgpSnapshotRunSnapshot {
|
||||
v: number
|
||||
job: "gre_bgp"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
fatalError?: string
|
||||
greWritten: number
|
||||
bgpWritten: number
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export type SchedulerRunSnapshot =
|
||||
| TrafficRunSnapshot
|
||||
| ResourcesRunSnapshot
|
||||
| PingRunSnapshot
|
||||
| SpeedScheduledRunSnapshot
|
||||
| ServersRestPingRunSnapshot
|
||||
| GreBgpSnapshotRunSnapshot
|
||||
| AlertEngineRunSnapshot
|
||||
|
||||
export interface TrafficServerSnapshot {
|
||||
serverId: number
|
||||
name: string
|
||||
host: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
interfaces?: number
|
||||
sumRxMbps?: number
|
||||
sumTxMbps?: number
|
||||
}
|
||||
|
||||
export interface TrafficRunSnapshot {
|
||||
v: number
|
||||
job: "traffic"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
fatalError?: string
|
||||
servers: TrafficServerSnapshot[]
|
||||
}
|
||||
|
||||
export interface ResourceServerSnapshot {
|
||||
serverId: number
|
||||
name: string
|
||||
host: string
|
||||
status: "online" | "offline"
|
||||
error?: string
|
||||
cpuLoadPct?: number
|
||||
memUsedMb?: number
|
||||
memTotalMb?: number
|
||||
memUsedPct?: number
|
||||
diskFreeMb?: number
|
||||
diskTotalMb?: number
|
||||
uptimeSeconds?: number
|
||||
boardName?: string
|
||||
rosVersion?: string
|
||||
}
|
||||
|
||||
export interface ResourcesRunSnapshot {
|
||||
v: number
|
||||
job: "uptime_resources"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
fatalError?: string
|
||||
servers: ResourceServerSnapshot[]
|
||||
}
|
||||
|
||||
export interface PingProbeSnapshot {
|
||||
probeId: string
|
||||
name: string
|
||||
target: string
|
||||
srcServerId: number
|
||||
srcServerName: string
|
||||
srcInterface: string
|
||||
ok: boolean
|
||||
rttMs: number | null
|
||||
lossPct: number
|
||||
status: "up" | "warn" | "down"
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PingRunSnapshot {
|
||||
v: number
|
||||
job: "uptime_ping"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
fatalError?: string
|
||||
probes: PingProbeSnapshot[]
|
||||
skippedByInterval?: number
|
||||
}
|
||||
|
||||
export interface SpeedRunSnapshot {
|
||||
probeId: string
|
||||
srcServerId: number
|
||||
srcServerName: string
|
||||
dstServerId: number
|
||||
dstServerName: string
|
||||
srcInterface: string
|
||||
dstInterface: string
|
||||
protocol: string
|
||||
direction: string
|
||||
durationSec: number
|
||||
ok: boolean
|
||||
txAvgMbps?: number | null
|
||||
rxAvgMbps?: number | null
|
||||
pingRttMs?: number | null
|
||||
pingLossPct?: number | null
|
||||
pingError?: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SpeedScheduledRunSnapshot {
|
||||
v: number
|
||||
job: "uptime_speed"
|
||||
sampledAt: string
|
||||
runs: SpeedRunSnapshot[]
|
||||
}
|
||||
|
||||
export function parseSchedulerRunSnapshot(raw: string | null | undefined): SchedulerRunSnapshot | null {
|
||||
if (raw == null || raw.trim() === "") return null
|
||||
try {
|
||||
const o = JSON.parse(raw) as { v?: unknown; job?: string }
|
||||
if (o?.v !== 1 || typeof o.job !== "string") return null
|
||||
return o as SchedulerRunSnapshot
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Типы и подписи для UI планировщика / страницы «Сбор данных» (совпадают с ответами API). */
|
||||
|
||||
/** Последний ключ — движок оповещений (читает БД после коллекторов; см. описание `alert_engine`). */
|
||||
export const SCHEDULER_JOB_KEYS = [
|
||||
"traffic",
|
||||
"servers_rest_ping",
|
||||
"uptime_resources",
|
||||
"uptime_ping",
|
||||
"uptime_speed",
|
||||
"gre_bgp",
|
||||
"alert_engine",
|
||||
] as const
|
||||
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
|
||||
|
||||
export const SCHEDULER_JOB_LABELS: Record<string, string> = {
|
||||
traffic: "Трафик",
|
||||
servers_rest_ping: "Серверы: REST API",
|
||||
uptime_resources: "Uptime: ресурсы",
|
||||
uptime_ping: "Uptime: ping",
|
||||
uptime_speed: "Uptime: speed",
|
||||
gre_bgp: "GRE + BGP",
|
||||
alert_engine: "Оповещения",
|
||||
}
|
||||
|
||||
/** Кратко — для раскрытой карточки прогона и подсказок. */
|
||||
export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
|
||||
traffic: "Опрос интерфейсов RouterOS, запись сэмплов трафика в SQLite.",
|
||||
servers_rest_ping:
|
||||
"Проверка доступности каталога серверов: GET /system/identity (RouterOS REST), замер задержки без полного опроса.",
|
||||
uptime_resources: "CPU, память, температура и др. метрики с устройств.",
|
||||
uptime_ping: "ICMP-пинг по настроенным пробам мониторинга.",
|
||||
uptime_speed: "Фоновые btest / speed-пробы между узлами (при включённых пробах).",
|
||||
gre_bgp:
|
||||
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.",
|
||||
alert_engine:
|
||||
"Оценка правил по данным из SQLite (сэмплы пишут джобы сбора, в т.ч. «GRE + BGP» и «Серверы: REST API»).",
|
||||
}
|
||||
|
||||
export interface CollectorSettingsDto {
|
||||
enabled: boolean
|
||||
intervalSec: number
|
||||
probeIntervalSec?: number
|
||||
speedIntervalSec?: number
|
||||
retentionDays: number
|
||||
lastCollectedAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
collectorRunning?: boolean
|
||||
}
|
||||
|
||||
export interface SchedulerJobStatusDto {
|
||||
jobKey: string
|
||||
enabled: boolean
|
||||
intervalSec: number
|
||||
running: boolean
|
||||
lastFinishedAt: string | null
|
||||
lastStatus: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
export interface SchedulerStatusDto {
|
||||
jobs: SchedulerJobStatusDto[]
|
||||
}
|
||||
|
||||
export interface UptimeSettingsDto extends CollectorSettingsDto {
|
||||
resourcesEnabled?: boolean
|
||||
pingEnabled?: boolean
|
||||
speedEnabled?: boolean
|
||||
scheduler?: SchedulerStatusDto
|
||||
}
|
||||
|
||||
export interface SchedulerRunRowDto {
|
||||
id: string
|
||||
jobKey: string
|
||||
startedAt: string
|
||||
finishedAt: string
|
||||
status: string
|
||||
error: string | null
|
||||
durationMs: number
|
||||
/** JSON снимка результатов прогона (серверы, пробы, speed и т.д.) */
|
||||
resultJson?: string | null
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
asns,
|
||||
domains,
|
||||
filters,
|
||||
greTunnels,
|
||||
ipRanges,
|
||||
pingProbes,
|
||||
routerCertificates,
|
||||
routerContainers,
|
||||
servers,
|
||||
} from "@/lib/data"
|
||||
|
||||
/** Число мок-сессий BGP (см. `SESSIONS` в `app/(main)/bgp/page.tsx`). */
|
||||
export const MOCK_BGP_SESSION_COUNT = 12
|
||||
|
||||
/** Компактная подпись: тысячи как «1.8к» (кириллица), как в старом меню. */
|
||||
export function formatSidebarBadgeCount(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return "0"
|
||||
if (n < 1000) return String(Math.round(n))
|
||||
const k = n / 1000
|
||||
const s = k >= 10 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, "")
|
||||
return `${s}к`
|
||||
}
|
||||
|
||||
function mockWireGuardIfacesCount(): number {
|
||||
let n = 0
|
||||
for (const s of servers) n += s.wireGuardIfaces?.length ?? 0
|
||||
return n
|
||||
}
|
||||
|
||||
/** Счётчики из `lib/data` для режима mock. */
|
||||
export function mockSidebarBadgesByUrl(): Record<string, string> {
|
||||
return {
|
||||
"/uptime": formatSidebarBadgeCount(pingProbes.length),
|
||||
"/domains": formatSidebarBadgeCount(domains.length),
|
||||
"/ip-ranges": formatSidebarBadgeCount(ipRanges.length),
|
||||
"/asns": formatSidebarBadgeCount(asns.length),
|
||||
"/servers": formatSidebarBadgeCount(servers.length),
|
||||
"/filters": formatSidebarBadgeCount(filters.length),
|
||||
"/wireguard": formatSidebarBadgeCount(mockWireGuardIfacesCount()),
|
||||
"/gre": formatSidebarBadgeCount(greTunnels.length),
|
||||
"/containers": formatSidebarBadgeCount(routerContainers.length),
|
||||
"/certificates": formatSidebarBadgeCount(routerCertificates.length),
|
||||
"/bgp": formatSidebarBadgeCount(MOCK_BGP_SESSION_COUNT),
|
||||
}
|
||||
}
|
||||
|
||||
export interface SidebarCountsDto {
|
||||
servers: number
|
||||
filterRules: number
|
||||
uptimeProbes: number
|
||||
uptimeSpeedProbes: number
|
||||
monitoringItems: number
|
||||
recursiveRoutes: number
|
||||
}
|
||||
Reference in New Issue
Block a user