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:
Denozordec
2026-05-07 12:29:04 +07:00
co-authored by Cursor
parent bdb9b72fac
commit 5f31bb47fb
81 changed files with 11976 additions and 1239 deletions
+2547 -149
View File
File diff suppressed because it is too large Load Diff
+8 -9
View File
@@ -57,8 +57,8 @@ export default function AsnsPage() {
</div>
<DataTable
data={rows}
searchPlaceholder="Поиск по ASN, организации…"
searchKeys={["asn", "org", "country"]}
searchPlaceholder="Поиск по ASN, имени, префиксам…"
searchKeys={["asn", "org", "prefixes"]}
columns={[
{
key: "asn",
@@ -67,13 +67,12 @@ export default function AsnsPage() {
},
{
key: "org",
label: "Организация",
render: (d) => <span className="font-medium">{d.org}</span>,
},
{
key: "country",
label: "Страна",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
label: "Имя / организация",
render: (d) => (
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={d.org}>
{d.org}
</span>
),
},
{
key: "prefixes",
+6 -6
View File
@@ -279,13 +279,13 @@ export default function CommunitiesPage() {
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs">
{c.prefixCount > 0 ? c.prefixCount.toLocaleString() : "—"}
<td className="px-4 py-2.5 text-right font-mono text-xs tabular-nums">
{c.prefixCount.toLocaleString("ru-RU")}
</td>
<td className="px-4 py-2.5 text-right">
<td className="px-4 py-2.5 text-right tabular-nums">
<div className="flex items-center justify-end gap-1">
<ServerIcon className="size-3 text-muted-foreground" />
<span className="font-mono text-xs">{c.serverCount}</span>
<span className="font-mono text-xs">{c.serverCount.toLocaleString("ru-RU")}</span>
</div>
</td>
<td className="px-4 py-2.5">
@@ -337,8 +337,8 @@ export default function CommunitiesPage() {
{[
["Тип", TYPE_LABELS[selected.type]],
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
["Маршрутов", selected.prefixCount > 0 ? selected.prefixCount.toLocaleString() : "—"],
["Серверов", String(selected.serverCount)],
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")],
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-2">
<span className="text-xs text-muted-foreground">{k}</span>
File diff suppressed because it is too large Load Diff
+159 -20
View File
@@ -27,12 +27,17 @@ import {
import {
collectGreEndpointHostnames,
greResolvedMapFromApi,
isIpv4String,
normalizeGreEndpointAddr,
} from "@/lib/gre-endpoint-resolve"
import {
assignSpeedProbesToGreTunnels,
assignSpeedProbesToWanJhEdges,
canonicalGreServerPairKey,
greOuterSummaryLine,
ifaceNameForGreOuterIp,
mergeGreMetricsWithSpeedProbe,
wanJhEdgeMapKey,
type GreSpeedProbeSnapshot,
} from "@/lib/map-gre-speed-probe"
import { Button } from "@/components/ui/button"
@@ -318,6 +323,12 @@ function edgeBadgePosition(
type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router"
function greEdgeKey(e: GreMapEdge): string {
const la = (e.tunnel.localAddress ?? "").trim()
const ra = (e.tunnel.remoteAddress ?? "").trim()
return `${e.fromServer.id}|${e.toServer.id}|${e.tunnel.id}|${la}|${ra}`
}
// ─── SVG sub-components ───────────────────────────────────────────────────────
/** Бейдж на ребре WAN → JumpHost (не GRE): сумма latency домашнего узла и JH — те же поля, что в разделе Серверы. */
@@ -697,7 +708,7 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
<rect width={W} height={H} fill="#060d1a" />
{/* simplified edges */}
{greEdges.map((e) => (
<line key={e.tunnel.id} x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
<line key={greEdgeKey(e)} x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
stroke={TUNNEL_STYLE[e.tunnel.status].stroke} strokeWidth="5" opacity="0.25" />
))}
{/* WAN edges */}
@@ -831,7 +842,18 @@ export default function NetworkMapPage() {
lastPingRttMs: p.lastPingRttMs,
})),
)
const hostnames = collectGreEndpointHostnames(mappedTunnels)
const hostSet = new Set<string>(collectGreEndpointHostnames(mappedTunnels))
for (const row of rows) {
if (!Array.isArray(row.wanUplinks)) continue
for (const w of row.wanUplinks) {
const raw = (w.ip ?? "").trim()
if (!raw) continue
const n = normalizeGreEndpointAddr(raw)
if (!n || n === "0.0.0.0") continue
if (!isIpv4String(n)) hostSet.add(n.toLowerCase())
}
}
const hostnames = [...hostSet]
if (hostnames.length > 0) {
try {
const res = await apiFetch<{ results: Record<string, string | null> }>(
@@ -900,6 +922,10 @@ export default function NetworkMapPage() {
try {
const k = "mm-network-map-layout-rev"
if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) {
// Важно: без явного сброса старые drag-координаты остаются в state
// и перекрывают новый авто-лейаут на live-данных.
setNodePositions({})
setSatPositions({})
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
}
} catch {
@@ -957,12 +983,21 @@ export default function NetworkMapPage() {
[mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap],
)
/** WAN→JH: не показывать PingBadge, если на том же дом+WAN+JH уже есть GRE с метрик-бейджем. */
/** Speed-пробы на сегментах WANJH (тот же формат бейджа, что на GRE JH↔EN). */
const speedProbeByWanJhKey = useMemo(
() => assignSpeedProbesToWanJhEdges(wanJhEdges, mapServers, speedProbes),
[wanJhEdges, mapServers, speedProbes],
)
/**
* WAN→JH: не дублировать линию спутник→пир, если уже есть GRE с того же WAN к тому же узлу
* (JH или exit — иначе «второй» бейдж speed посередине сегмента).
*/
const suppressWanJhPingBadge = useMemo(() => {
const s = new Set<string>()
for (const g of greEdges) {
if (g.fromServer.type !== "home-router") continue
if (g.toServer.type !== "jump-host") continue
if (g.toServer.type === "home-router") continue
const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
if (widx == null) continue
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
@@ -986,7 +1021,7 @@ export default function NetworkMapPage() {
() =>
assignSpeedProbesToGreTunnels(
greEdges.map((e) => ({
tunnel: e.tunnel,
tunnel: { ...e.tunnel, id: greEdgeKey(e) },
fromServer: e.fromServer,
toServer: e.toServer,
})),
@@ -996,14 +1031,77 @@ export default function NetworkMapPage() {
[greEdges, speedProbes, greResolvedMap],
)
/** WAN→JH: не показывать GreEdgeMetricBadge со speed, если те же метрики уже на GRE (один бейдж на сегмент). */
const suppressWanJhSpeedBadgeDuplicate = useMemo(() => {
const s = new Set<string>()
for (const g of greEdges) {
if (g.fromServer.type !== "home-router") continue
if (g.toServer.type === "home-router") continue
const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
if (widx == null) continue
const spGre = speedProbeByTunnelId.get(greEdgeKey(g))
const merged = mergeGreMetricsWithSpeedProbe(spGre, greTunnelProbe(g.tunnel))
if (!merged.hasSpeedMonitor) continue
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
}
return s
}, [greEdges, speedProbeByTunnelId, greResolvedMap])
/** WAN→JH: скрываем саму линию, если тот же отрезок уже отрисован GRE (одна связь на карте). */
const suppressWanJhLineDuplicate = useMemo(() => {
const s = new Set<string>()
for (const g of greEdges) {
if (g.fromServer.type !== "home-router") continue
if (g.toServer.type === "home-router") continue
const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
if (widx == null) continue
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
}
return s
}, [greEdges, greResolvedMap])
const visibleWanJhEdges = useMemo(
() => wanJhEdges.filter((e) => !suppressWanJhLineDuplicate.has(wanJhEdgeMapKey(e))),
[wanJhEdges, suppressWanJhLineDuplicate],
)
/** Один бейдж на совпадающий отрезок карты (два туннеля в БД HR→MSK и MSK→HR — одна линия). */
const greTunnelIdsWithMapBadge = useMemo(() => {
const edgeByTunnel = new Map(greEdges.map((e) => [greEdgeKey(e), e] as const))
const geomKey = (e: GreMapEdge) => {
const a = `${Math.round(e.from.x)},${Math.round(e.from.y)}`
const b = `${Math.round(e.to.x)},${Math.round(e.to.y)}`
return a <= b ? `${a}|${b}` : `${b}|${a}`
}
const rank = (tid: string): number => {
const e = edgeByTunnel.get(tid)
if (!e) return -1
const sp = speedProbeByTunnelId.get(tid)
const m = mergeGreMetricsWithSpeedProbe(sp, greTunnelProbe(e.tunnel))
let r = m.hasSpeedMonitor ? 1000 : 0
r += m.pingMs ?? 0
return r
}
const winners = new Map<string, string>()
for (const e of greEdges) {
const key = geomKey(e)
const cur = winners.get(key)
const edgeId = greEdgeKey(e)
if (!cur || rank(edgeId) > rank(cur) || (rank(edgeId) === rank(cur) && edgeId.localeCompare(cur) < 0)) {
winners.set(key, edgeId)
}
}
return new Set(winners.values())
}, [greEdges, speedProbeByTunnelId])
/** Несколько GRE между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */
const greBadgeStaggerByTunnelId = useMemo(() => {
const pairOrder = new Map<string, number>()
const out = new Map<string, number>()
for (const ge of greEdges) {
const k = `${ge.fromServer.id}:${ge.toServer.id}`
const k = canonicalGreServerPairKey(ge.fromServer, ge.toServer)
const lane = pairOrder.get(k) ?? 0
out.set(ge.tunnel.id, lane)
out.set(greEdgeKey(ge), lane)
pairOrder.set(k, lane + 1)
}
return out
@@ -1216,6 +1314,16 @@ export default function NetworkMapPage() {
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
}
function openWanJhSpeedDetail(ev: React.MouseEvent<SVGElement>, edge: WanJhEdge) {
ev.stopPropagation()
const home = mapServers.find((s) => s.id === edge.homeId)
if (!home) return
setSelectedGreEdge(null)
setSelected(home)
setSelWanIdx(edge.wanIdx)
setHoveredId(null)
}
const connectedTunnels = selected
? mapGreTunnels.filter((t) => {
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
@@ -1410,6 +1518,7 @@ export default function NetworkMapPage() {
{/* ── GRE edges ── */}
{greEdges.map((e, i) => {
const edgeId = greEdgeKey(e)
const ts = TUNNEL_STYLE[e.tunnel.status]
const fromN = nodeById[e.fromServer.id]
const toN = nodeById[e.toServer.id]
@@ -1419,12 +1528,12 @@ export default function NetworkMapPage() {
!isVisible(fromN) &&
!isVisible(toN)
const baseProbe = greTunnelProbe(e.tunnel)
const spGre = speedProbeByTunnelId.get(e.tunnel.id)
const spGre = speedProbeByTunnelId.get(edgeId)
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
const rttFromMonitor = spGre?.lastPingRttMs != null
const throughputFromMonitor =
spGre?.lastTxAvgMbps != null || spGre?.lastRxAvgMbps != null
const badgeLane = greBadgeStaggerByTunnelId.get(e.tunnel.id) ?? 0
const badgeLane = greBadgeStaggerByTunnelId.get(edgeId) ?? 0
const tBadge = Math.min(0.78, Math.max(0.22, 0.34 + badgeLane * 0.052))
const normalPx =
(badgeLane % 2 === 0 ? 1 : -1) * (16 + badgeLane * 22)
@@ -1443,7 +1552,7 @@ export default function NetworkMapPage() {
setSelWanIdx(null)
}
return (
<g key={e.tunnel.id} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
<line
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
stroke={ts.stroke} strokeWidth="1.5"
@@ -1467,7 +1576,7 @@ export default function NetworkMapPage() {
path={`M ${e.from.x} ${e.from.y} L ${e.to.x} ${e.to.y}`} />
</circle>
)}
{showPingBadges && (
{showPingBadges && greTunnelIdsWithMapBadge.has(edgeId) && (
<GreEdgeMetricBadge
mx={mx}
my={my}
@@ -1503,7 +1612,7 @@ export default function NetworkMapPage() {
})}
{/* ── WAN→JH edges ── */}
{wanJhEdges.map((edge, i) => {
{visibleWanJhEdges.map((edge, i) => {
const jh = nodeById[edge.jhId]
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
if (!jh || !satPos) return null
@@ -1528,10 +1637,35 @@ export default function NetworkMapPage() {
path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
</circle>
)}
{showPingBadges &&
!suppressWanJhPingBadge.has(`${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`) && (
<PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
)}
{showPingBadges && (() => {
const kj = wanJhEdgeMapKey(edge)
const sp = speedProbeByWanJhKey.get(kj)
const merged = mergeGreMetricsWithSpeedProbe(sp, {
pingMs: edge.pingMs,
dlMbps: null,
ulMbps: null,
})
const hasMonitor = merged.hasSpeedMonitor
if (!hasMonitor && suppressWanJhPingBadge.has(kj)) return null
if (hasMonitor && suppressWanJhSpeedBadgeDuplicate.has(kj)) return null
if (hasMonitor) {
return (
<GreEdgeMetricBadge
mx={mx}
my={my}
pingMs={merged.pingMs}
dl={merged.dlMbps}
ul={merged.ulMbps}
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
rttFromMonitor={sp?.lastPingRttMs != null}
throughputFromMonitor={
sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null
}
/>
)
}
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
})()}
</g>
)
})}
@@ -1677,7 +1811,7 @@ export default function NetworkMapPage() {
nodes={nodes}
greEdges={greEdges}
satPos={effectiveSatPos}
wanJhEdges={wanJhEdges}
wanJhEdges={visibleWanJhEdges}
homeRouters={homeRouters}
onClose={() => setShowMinimap(false)}
onPan={(x, y) => setPan({ x, y })}
@@ -1743,10 +1877,11 @@ export default function NetworkMapPage() {
{(() => {
const edge = selectedGreEdge
const t = edge.tunnel
const selectedEdgeId = greEdgeKey(edge)
const localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap)
const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap)
const baseProbe = greTunnelProbe(t)
const spGr = speedProbeByTunnelId.get(t.id)
const spGr = speedProbeByTunnelId.get(selectedEdgeId)
const merged = mergeGreMetricsWithSpeedProbe(spGr, baseProbe)
const rttMon = spGr?.lastPingRttMs != null
const bwMon = spGr?.lastTxAvgMbps != null || spGr?.lastRxAvgMbps != null
@@ -2039,16 +2174,20 @@ export default function NetworkMapPage() {
: mapServers.find((s) => s.id === t.serverId)
const fromServer = mapServers.find((s) => s.id === t.serverId)
const toServer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
const tunnelPanelKey =
fromServer && toServer
? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}`
: `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}`
const baseProbe = greTunnelProbe(t)
const spGre =
fromServer && toServer ? speedProbeByTunnelId.get(t.id) : undefined
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
const pc = pingColor(merged.pingMs)
const showMetrics =
merged.pingMs != null ||
(merged.dlMbps != null && merged.ulMbps != null)
return (
<div key={t.id} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
<div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-mono font-medium">{t.name}</span>
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
+118 -9
View File
@@ -12,6 +12,7 @@ import {
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -119,6 +120,28 @@ interface BackendOspfAll {
bfdSessions: BackendBfdSession[]
}
function isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
}
interface BackendOspfOptimizeResponse {
serverId: number
serverName: string
pingWeight: number
optimizedCount: number
applied: Array<{ interface: string; from: number; to: number }>
interfaces: Array<{
id: string
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
}
// ─── backend → frontend mappers ───────────────────────────────────────────────
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map<string, number>): OspfNeighbor {
@@ -625,11 +648,25 @@ function NodeDetailPanel({
// ─── interfaces tab ───────────────────────────────────────────────────────────
function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isLive: boolean }) {
function InterfacesTab({
items: initialItems,
isLive,
filterServerId,
backendUrl,
onLiveDataRefresh,
}: {
items: OspfItem[]
isLive: boolean
filterServerId: string
backendUrl: string
onLiveDataRefresh: () => void
}) {
const [items, setItems] = useState<OspfItem[]>(initialItems)
const [dragging, setDragging] = useState<string | null>(null)
const [dragOver, setDragOver] = useState<string | null>(null)
const [toast, setToast] = useState<string | null>(null)
const [optimizing, setOptimizing] = useState(false)
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
// Sync with live data when it changes
useEffect(() => {
@@ -660,17 +697,67 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
if (isLive) return {}
const out: Record<string, { optimalCost: number; prob: number }> = {}
grouped.forEach(router => {
router.areas.forEach(ag => {
const ranked = [...ag.items]
.map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 }))
.sort((a, b) => b.prob - a.prob || a.item.interfaceName.localeCompare(b.item.interfaceName))
ranked.forEach(({ item, prob }, idx) => { out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob } })
const ranked = router.areas
.flatMap(ag => ag.items)
.map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 }))
.sort((a, b) => b.prob - a.prob || a.item.interfaceName.localeCompare(b.item.interfaceName))
// В рамках одного роутера выдаём строго уникальные optimal cost.
ranked.forEach(({ item, prob }, idx) => {
out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob }
})
})
return out
}, [grouped, isLive])
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
const canOptimizeLive = isLive && filterServerId !== "all"
const uniqueLiveFallbackOpt = useMemo(() => {
const out: Record<string, number> = {}
const byRouter: Record<string, OspfItem[]> = {}
items.forEach((item) => {
if (!byRouter[item.routerKey]) byRouter[item.routerKey] = []
byRouter[item.routerKey].push(item)
})
Object.values(byRouter).forEach((routerItems) => {
routerItems
.slice()
.sort((a, b) => a.cost - b.cost || a.interfaceName.localeCompare(b.interfaceName))
.forEach((item, idx) => {
out[item.key] = (idx + 1) * COST_STEP
})
})
return out
}, [items])
async function handleLiveOptimize() {
if (!canOptimizeLive) {
showToast("Выберите конкретный сервер для оптимизации OSPF")
return
}
const ra = readStoredRouteOptimizerSettings()
setOptimizing(true)
try {
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pingWeight: ra.pingWeight }),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json() as BackendOspfOptimizeResponse
const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
})
setLiveOptimalCost(byKey)
showToast(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
onLiveDataRefresh()
} catch (err) {
showToast(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setOptimizing(false)
}
}
function onDrop(routerKey: string, area: string, targetKey: string) {
if (!dragging || dragging === targetKey) { setDragging(null); setDragOver(null); return }
@@ -704,6 +791,12 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
<WandSparklesIcon className="size-4" />Оптимизировать
</Button>
)}
{canOptimizeLive && (
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing}>
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin")} />
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
</Button>
)}
{!isLive && (
<Button size="sm" onClick={() => showToast("OSPF Interface Templates применены")}>
<SaveIcon className="size-4" />Сохранить
@@ -750,6 +843,8 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
{ag.items.map(item => {
const hint = hints[item.key]
const matches = hint && hint.optimalCost === item.cost
const liveOptimal = liveOptimalCost[item.key] ?? uniqueLiveFallbackOpt[item.key] ?? item.cost
const costDiffers = liveOptimal !== item.cost
return (
<div key={item.key}
draggable={!isLive}
@@ -779,7 +874,11 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
}>opt {hint.optimalCost}</Chip>
)}
<Chip color="bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20">cost {item.cost}</Chip>
<Chip color="bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20">cur {item.cost}</Chip>
<Chip color={costDiffers
? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
}>opt {liveOptimal}</Chip>
</div>
)
})}
@@ -1184,7 +1283,9 @@ export default function OspfPage() {
ifaceMap.set(`${iface.serverId}::${iface.interface}`, iface.cost)
}
const items = liveData.interfaces.map(backendToItem)
const items = liveData.interfaces
.map(backendToItem)
.filter((item) => !isRefInterfaceName(item.interfaceName))
const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
@@ -1389,7 +1490,15 @@ export default function OspfPage() {
))}
</div>
{activeTab === "interfaces" && <InterfacesTab items={displayItems} isLive={isLive} />}
{activeTab === "interfaces" && (
<InterfacesTab
items={displayItems}
isLive={isLive}
filterServerId={filterServerId}
backendUrl={backendUrl}
onLiveDataRefresh={() => setFetchTick(t => t + 1)}
/>
)}
{activeTab === "neighbors" && (
<NeighborsTab
neighbors={displayNeighbors}
+12 -4
View File
@@ -529,15 +529,20 @@ export default function RecursiveRoutesPage() {
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null)
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
const [liveServerListReady, setLiveServerListReady] = useState(false)
useEffect(() => {
if (!isLive) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(true)
setServers(mockServers)
setSelectedServerId(mockServers[0]?.id ?? "")
setRows([])
return
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(false)
apiFetch<BackendServer[]>("/api/servers")
.then((data) => {
const mapped = data.map(mapBackendServer)
@@ -548,10 +553,13 @@ export default function RecursiveRoutesPage() {
setServers([])
setSelectedServerId("")
})
.finally(() => {
setLiveServerListReady(true)
})
}, [isLive, apiFetch])
const loadRoutes = useCallback(async () => {
if (!isLive || !selectedServerId) return
if (!isLive || !liveServerListReady || !selectedServerId) return
setOpError(null)
setBusy("load")
try {
@@ -563,17 +571,17 @@ export default function RecursiveRoutesPage() {
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch])
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
const loadGateways = useCallback(async () => {
if (!isLive || !selectedServerId) return
if (!isLive || !liveServerListReady || !selectedServerId) return
try {
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
setGatewayOptions(res.gateways)
} catch {
setGatewayOptions([])
}
}, [isLive, selectedServerId, apiFetch])
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
+273 -12
View File
@@ -22,11 +22,13 @@ import {
type OptimizerData,
type OptimizerSettings,
type OptimizerApiServer,
type RouteOptimizerSpeedProbe,
buildLiveOptimizerData,
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
mapApiServersToTopology,
readStoredRouteOptimizerSettings,
ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY,
calcRouteScore,
} from "@/lib/route-optimizer-data"
import {
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
@@ -93,13 +95,6 @@ function jitter(base: number, range: number) {
return Math.max(1, Math.round(base + (Math.random() - 0.5) * range * 2))
}
function calcScore(pingMs: number, dlMbps: number, ulMbps: number, pw: number) {
const pingScore = Math.max(0, 100 - pingMs * 0.6)
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
const w = pw / 100
return Math.round(w * pingScore + (1 - w) * speedScore)
}
function confidence(prob: number): "HIGH" | "MEDIUM" | "LOW" {
return prob >= 55 ? "HIGH" : prob >= 30 ? "MEDIUM" : "LOW"
}
@@ -161,7 +156,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData {
wanJhLegs.push({
wanId: wan.id, jhId: jh.id, pingMs: ping, dlMbps: dl, ulMbps: ul,
loss: base.loss > 0 ? +(base.loss + (Math.random() - 0.5) * 0.5).toFixed(1) : 0,
score: calcScore(ping, dl, ul, pw),
score: calcRouteScore(ping, dl, ul, pw),
})
}
}
@@ -177,7 +172,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData {
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, wan, jh, exit: ex, hw, je,
@@ -521,7 +516,7 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
</tr>
</thead>
<tbody className="divide-y divide-border">
{recs.map(r => {
{recs.map((r, idx) => {
const pinKey = `${homeId}::${r.community}`
const isPinned = pinned.has(pinKey)
const isApplied = applied.has(pinKey)
@@ -529,7 +524,7 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
const canApply = r.shouldSwitch && !isPinned && !isApplied
return (
<tr key={r.community} className={cn(
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
"hover:bg-muted/30 transition-colors",
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
isApplied && "bg-emerald-500/5",
@@ -782,6 +777,7 @@ export default function RouteOptimizerPage() {
const [liveJumpHosts, setLiveJumpHosts] = useState<JumpHost[]>([])
const [liveExitNodes, setLiveExitNodes] = useState<ExitNode[]>([])
const [liveServers, setLiveServers] = useState<OptimizerApiServer[]>([])
const [showSettings, setShowSettings] = useState(false)
const [pinned, setPinned] = useState<Set<string>>(new Set())
@@ -792,6 +788,34 @@ export default function RouteOptimizerPage() {
const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst")
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
const [selectedVrf, setSelectedVrf] = useState("main")
const [ospfServerId, setOspfServerId] = useState("")
const [ospfApplying, setOspfApplying] = useState(false)
const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null)
const [ospfApplyError, setOspfApplyError] = useState("")
const [ospfMeta, setOspfMeta] = useState<{ interfaces: number; areas: number; serverName: string } | null>(null)
const [ospfPreviewError, setOspfPreviewError] = useState("")
const [ospfPreview, setOspfPreview] = useState<{
changedCount: number
interfacesTotal: number
interfaces: Array<{
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
changes: Array<{
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
} | null>(null)
const load = useCallback(async (override?: OptimizerSettings) => {
const s = override ?? settingsRef.current
@@ -803,9 +827,13 @@ export default function RouteOptimizerPage() {
setData(buildMockData(s))
setLiveJumpHosts([])
setLiveExitNodes([])
setLiveServers([])
return
}
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
const enabledRows = rows.filter((r) => r.enabled)
setLiveServers(enabledRows)
setOspfServerId((prev) => (prev && enabledRows.some((r) => String(r.id) === prev) ? prev : String(enabledRows[0]?.id ?? "")))
const { jumpHosts, exitNodes } = mapApiServersToTopology(rows)
setLiveJumpHosts(jumpHosts)
setLiveExitNodes(exitNodes)
@@ -819,13 +847,21 @@ export default function RouteOptimizerPage() {
} catch {
rulesets = null
}
let speedProbes: RouteOptimizerSpeedProbe[] = []
try {
const sp = await apiFetch<{ probes: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes")
speedProbes = sp.probes ?? []
} catch {
speedProbes = []
}
setData(buildLiveOptimizerData(rows, rulesets, s))
setData(buildLiveOptimizerData(rows, rulesets, s, speedProbes))
} catch (e) {
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
setData(null)
setLiveJumpHosts([])
setLiveExitNodes([])
setLiveServers([])
} finally {
setLoading(false)
}
@@ -929,9 +965,104 @@ export default function RouteOptimizerPage() {
)
}
async function applyOspfOptimization() {
if (!ospfServerId) return
setOspfApplying(true)
setOspfApplyError("")
setOspfApplyResult(null)
try {
const res = await apiFetch<{ serverName: string; optimizedCount: number }>(
`/api/servers/${ospfServerId}/ospf/optimize`,
{
method: "POST",
body: JSON.stringify({ pingWeight: settings.pingWeight }),
},
)
setOspfApplyResult({
serverName: res.serverName,
optimizedCount: res.optimizedCount,
})
await load()
setOspfPreview((prev) => (prev ? { ...prev, changedCount: 0, changes: [] } : prev))
} catch (e) {
setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации")
} finally {
setOspfApplying(false)
}
}
const set = <K extends keyof OptimizerSettings>(k: K, v: OptimizerSettings[K]) =>
setSettings(prev => ({ ...prev, [k]: v }))
useEffect(() => {
if (!useLiveData || !ospfServerId) return
let cancelled = false
void apiFetch<{
interfaces: Array<{ areaId: string; interface: string }>
instances: Array<unknown>
neighbors: Array<unknown>
bfdSessions: Array<unknown>
}>(`/api/servers/${ospfServerId}/ospf`)
.then((data) => {
if (cancelled) return
const selected = liveServers.find((s) => String(s.id) === ospfServerId)
const visibleInterfaces = data.interfaces.filter((i) => !/^\(ref\s+\*.+\)$/.test(i.interface.trim()))
setOspfMeta({
interfaces: visibleInterfaces.length,
areas: new Set(visibleInterfaces.map((i) => i.areaId)).size,
serverName: selected?.name || selected?.host || ospfServerId,
})
})
.catch(() => {
if (cancelled) return
setOspfMeta(null)
})
return () => { cancelled = true }
}, [apiFetch, liveServers, ospfServerId, useLiveData])
useEffect(() => {
if (!useLiveData || !ospfServerId) return
let cancelled = false
void apiFetch<{
changedCount: number
interfacesTotal: number
interfaces: Array<{
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
changes: Array<{
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
}>(
`/api/servers/${ospfServerId}/ospf/optimize/preview`,
{ method: "POST", body: JSON.stringify({ pingWeight: settings.pingWeight }) },
)
.then((data) => {
if (cancelled) return
setOspfPreviewError("")
setOspfPreview(data)
})
.catch((e) => {
if (cancelled) return
setOspfPreview(null)
setOspfPreviewError(e instanceof Error ? e.message : "Ошибка preview OSPF")
})
return () => { cancelled = true }
}, [apiFetch, ospfServerId, settings.pingWeight, useLiveData])
const ospfPreviewLoading = useLiveData && Boolean(ospfServerId) && !ospfPreview && !ospfPreviewError
return (
<div className="flex flex-col h-full">
<PageHeader
@@ -1081,6 +1212,136 @@ export default function RouteOptimizerPage() {
)}
</Card>
{/* OSPF optimization from Route Optimizer */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-2.5">
<div className="flex items-center gap-2 text-sm">
<NetworkIcon className="size-4 text-sky-400 shrink-0" />
<span className="font-semibold">OSPF</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
Route AI weight: ping {settings.pingWeight}%
</span>
</div>
{!useLiveData && (
<p className="text-xs text-muted-foreground">
Доступно только в режиме живых данных.
</p>
)}
{useLiveData && (
<>
<div className="rounded-lg border bg-muted/15 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b bg-background/80 flex-wrap">
<select
className="text-xs bg-background text-foreground border border-input rounded-md px-2 py-1 h-7 min-w-64 focus:outline-none focus:ring-1 focus:ring-ring"
value={ospfServerId}
onChange={(e) => setOspfServerId(e.target.value)}
>
{liveServers.length === 0 && <option value="">Нет доступных серверов</option>}
{liveServers.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name || s.host} ({s.host})
</option>
))}
</select>
<span className="text-[11px] text-muted-foreground">
{ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"}
</span>
<Button
size="sm"
className="ml-auto h-7 text-xs"
onClick={() => void applyOspfOptimization()}
disabled={!ospfServerId || ospfApplying}
>
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
</Button>
</div>
<div className="px-3 py-2 text-xs grid grid-cols-1 md:grid-cols-4 gap-2">
<div className="text-muted-foreground">Router</div>
<div className="md:col-span-2 font-mono truncate">{ospfMeta?.serverName ?? "—"}</div>
<div className="text-right text-muted-foreground">
apply: {ospfApplyResult?.optimizedCount ?? 0}
</div>
</div>
</div>
<div className="rounded-lg border bg-background/70 overflow-hidden">
<div className="px-3 py-2 border-b text-[11px] text-muted-foreground flex items-center justify-between">
<span>Preview изменений OSPF cost (до применения)</span>
<span>
{ospfPreviewLoading
? "расчёт…"
: ospfPreview
? `${ospfPreview.changedCount} из ${ospfPreview.interfacesTotal} изменятся`
: "нет данных"}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/30">
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => (
<th key={h} className="text-left px-3 py-1.5 font-medium text-muted-foreground">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{ospfPreviewError && (
<tr>
<td colSpan={5} className="px-3 py-2 text-destructive">
Ошибка preview: {ospfPreviewError}
</td>
</tr>
)}
{!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && (
<tr>
<td colSpan={5} className="px-3 py-2 text-muted-foreground">
Интерфейсы OSPF не найдены для выбранного сервера.
</td>
</tr>
)}
{(ospfPreview?.interfaces ?? []).map((row) => (
<tr key={`${row.interface}-${row.currentCost}-${row.optimalCost}`}>
<td className="px-3 py-1.5 font-mono">{row.interface}</td>
<td className="px-3 py-1.5 font-mono">
<span className="text-sky-600 dark:text-sky-400">{row.currentCost}</span>
{" → "}
<span className={row.currentCost === row.optimalCost
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400"}
>
{row.optimalCost}
</span>
</td>
<td className="px-3 py-1.5 font-mono">{row.score}</td>
<td className="px-3 py-1.5 font-mono">{row.pingMs}ms</td>
<td className="px-3 py-1.5 font-mono">{row.dlMbps} / {row.ulMbps}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{ospfApplyResult && (
<div className="text-xs rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-600 dark:text-emerald-400">
Применено на {ospfApplyResult.serverName}: изменено интерфейсов {ospfApplyResult.optimizedCount}.
</div>
)}
{ospfApplyError && (
<div className="text-xs rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
Ошибка OSPF-оптимизации: {ospfApplyError}
</div>
)}
</>
)}
</CardContent>
</Card>
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
+2 -189
View File
@@ -61,7 +61,7 @@ const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[
{ group: "Данные", icon: <EyeIcon className="size-3" />, items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
{ group: "Управление", icon: <WrenchIcon className="size-3" />, items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
{ group: "Инструменты", icon: <ShieldIcon className="size-3" />, items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] },
{ group: "Система", icon: <ServerIcon className="size-3" />, items: ["Настройки"] },
{ group: "Система", icon: <ServerIcon className="size-3" />, items: ["Оповещения", "Сбор данных", "Настройки"] },
]
const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items)
@@ -147,21 +147,9 @@ const PERM_COLOR: Record<PermLevel, string> = {
write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
}
const SECTIONS_NAV = ["Общие", "Сбор данных", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
type NavSection = typeof SECTIONS_NAV[number]
interface CollectorSettingsDto {
enabled: boolean
intervalSec: number
probeIntervalSec?: number
speedIntervalSec?: number
retentionDays: number
lastCollectedAt: string | null
lastDurationMs: number | null
lastError: string | null
collectorRunning?: boolean
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
@@ -864,17 +852,6 @@ export default function SettingsPage() {
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
const [showEvoKey, setShowEvoKey] = useState(false)
// collectors
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<CollectorSettingsDto | null>(null)
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15")
const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60")
const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14")
const [collectorBusy, setCollectorBusy] = useState<"traffic" | "uptime" | null>(null)
const [collectorError, setCollectorError] = useState<string | null>(null)
// general
const [lang, setLang] = useState("ru")
const [theme, setTheme] = useState("system")
@@ -927,31 +904,6 @@ export default function SettingsPage() {
// total sub-users count for summary
const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0)
const loadCollectors = useCallback(async () => {
if (mode !== "live" || backendStatus !== true) return
setCollectorError(null)
try {
const [traffic, uptime] = await Promise.all([
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/uptime/settings"),
])
setTrafficCollector(traffic)
setUptimeCollector(uptime)
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? uptime.intervalSec))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить настройки сборщиков")
}
}, [apiFetch, backendStatus, mode])
useEffect(() => {
if (section !== "Сбор данных") return
queueMicrotask(() => { void loadCollectors() })
}, [section, loadCollectors])
useEffect(() => {
if (section !== "EvoBGP") return
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
@@ -1178,145 +1130,6 @@ export default function SettingsPage() {
</div>
)
// ── Сбор данных ──
if (section === "Сбор данных") return (
<div className="space-y-4">
{(mode !== "live" || backendStatus !== true) && (
<Card>
<CardContent className="pt-4 pb-4 px-4">
<p className="text-sm font-medium">Раздел доступен только в live-режиме</p>
<p className="text-xs text-muted-foreground mt-1">
Переключи `Режим данных` в `Живые` и проверь доступность бекенда в разделе `Общие`.
</p>
</CardContent>
</Card>
)}
{mode === "live" && backendStatus === true && (
<>
{collectorError && (
<Card>
<CardContent className="pt-4 pb-4 px-4">
<p className="text-xs text-destructive">{collectorError}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Сбор трафика</CardTitle>
<CardDescription className="text-xs">Настройки для `/traffic`</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5">
<div className="flex items-center gap-3">
<span className="text-sm">Состояние</span>
<div className="flex rounded-md border border-input overflow-hidden h-8">
<button className={cn("px-3 text-xs", trafficCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "traffic"}
onClick={async () => {
setCollectorBusy("traffic")
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Вкл</button>
<button className={cn("px-3 text-xs border-l border-input", !trafficCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "traffic"}
onClick={async () => {
setCollectorBusy("traffic")
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Выкл</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input value={trafficIntervalDraft} onChange={(e) => setTrafficIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал (сек)" />
<Input value={trafficRetentionDraft} onChange={(e) => setTrafficRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
setCollectorBusy("traffic")
try {
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ intervalSec: Number.parseInt(trafficIntervalDraft, 10) || 30, retentionDays: Number.parseInt(trafficRetentionDraft, 10) || 14 }) })
await loadCollectors()
} finally { setCollectorBusy(null) }
}}>Сохранить</Button>
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
setCollectorBusy("traffic")
try { await apiFetch("/api/traffic/collect-now", { method: "POST" }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Собрать сейчас</Button>
</div>
<div className="text-xs text-muted-foreground">
<p>Последний сбор: {trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
<p>Длительность: {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}</p>
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>{trafficCollector?.lastError ? `Ошибка: ${trafficCollector.lastError}` : "Ошибок нет"}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Сбор uptime</CardTitle>
<CardDescription className="text-xs">Настройки для `/uptime`</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5">
<div className="flex items-center gap-3">
<span className="text-sm">Состояние</span>
<div className="flex rounded-md border border-input overflow-hidden h-8">
<button className={cn("px-3 text-xs", uptimeCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "uptime"}
onClick={async () => {
setCollectorBusy("uptime")
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Вкл</button>
<button className={cn("px-3 text-xs border-l border-input", !uptimeCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "uptime"}
onClick={async () => {
setCollectorBusy("uptime")
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Выкл</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input value={uptimeIntervalDraft} onChange={(e) => setUptimeIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал ping-проб (сек)" />
<Input value={uptimeSpeedIntervalDraft} onChange={(e) => setUptimeSpeedIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал speed-проб (сек)" />
</div>
<div className="grid grid-cols-1 gap-3">
<Input value={uptimeRetentionDraft} onChange={(e) => setUptimeRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
setCollectorBusy("uptime")
try {
await apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
probeIntervalSec: Number.parseInt(uptimeIntervalDraft, 10) || 15,
speedIntervalSec: Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60,
retentionDays: Number.parseInt(uptimeRetentionDraft, 10) || 14,
}),
})
await loadCollectors()
} finally { setCollectorBusy(null) }
}}>Сохранить</Button>
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
setCollectorBusy("uptime")
try { await apiFetch("/api/uptime/collect-now", { method: "POST" }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Собрать сейчас</Button>
</div>
<div className="text-xs text-muted-foreground">
<p>Последний сбор: {uptimeCollector?.lastCollectedAt ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
<p>Длительность: {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}</p>
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>{uptimeCollector?.lastError ? `Ошибка: ${uptimeCollector.lastError}` : "Ошибок нет"}</p>
</div>
</CardContent>
</Card>
</>
)}
</div>
)
// ── EvoBGP ──
if (section === "EvoBGP") return (
<div className="space-y-4">
+14 -5
View File
@@ -290,8 +290,17 @@ const userTraffic: UserTraffic[] = [
// ─── types ────────────────────────────────────────────────────────────────────
const RANGES = ["5м", "15м", "1ч", "4ч", "24ч"] as const
type Range = typeof RANGES[number]
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const
type Range = (typeof TRAFFIC_RANGE_KEYS)[number]
const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
"5m": "5м",
"15m": "15м",
"1h": "1ч",
"4h": "4ч",
"24h": "24ч",
}
type GroupMode = "servers" | "users" | "gre"
type SortField = "rx" | "tx" | "name" | "sessions"
@@ -575,10 +584,10 @@ function DetailHeader({ range, setRange, children }: {
<div className="flex items-start justify-between mb-3 gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0">{children}</div>
<div className="flex gap-1 shrink-0">
{RANGES.map(r => (
{TRAFFIC_RANGE_KEYS.map((r) => (
<Button key={r} size="sm" variant={r === range ? "default" : "ghost"}
className="h-7 px-2 text-xs" onClick={() => setRange(r)}>
{r}
{TRAFFIC_RANGE_LABELS[r]}
</Button>
))}
</div>
@@ -772,7 +781,7 @@ export default function TrafficPage() {
const [sortField, setSortField] = useState<SortField>("rx")
const [sortDir, setSortDir] = useState<SortDir>("desc")
const [selectedId, setSelectedId] = useState("srv1")
const [range, setRange] = useState<Range>("1ч")
const [range, setRange] = useState<Range>("1h")
const [search, setSearch] = useState("")
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
const [liveBusy, setLiveBusy] = useState(false)
+461 -166
View File
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Sparkline } from "@/components/sparkline"
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
import { cn } from "@/lib/utils"
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
import type { PingProbe } from "@/lib/data"
@@ -29,6 +30,11 @@ import {
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
/** Звёздочка «на дашборде» для mock — общий ключ с `dashboard/page.tsx` */
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
@@ -85,7 +91,7 @@ function jitter(base: number, pct: number) {
function rttColor(rtt: number | null, loss: number): string {
if (rtt === null || loss >= 100) return "text-[var(--status-offline-fg)]"
if (loss > 1 || rtt > 60) return "text-[var(--status-degraded-fg)]"
if (loss > 1 || rtt > PING_PROBE_WARN_RTT_MS) return "text-[var(--status-degraded-fg)]"
return "text-[var(--status-online-fg)]"
}
@@ -93,6 +99,131 @@ function probeSparkColor(status: PingProbe["status"]): string {
return status === "down" ? "var(--status-offline)" : status === "warn" ? "var(--status-degraded)" : "var(--status-online)"
}
/** Развёрнутый график RTT под мини-спарклайном (та же серия `PingProbe.series`). */
function ProbePingRttDetailChart({
series,
status,
probeName,
target,
}: {
series: number[]
status: PingProbe["status"]
probeName: string
target: string
}) {
const stroke = probeSparkColor(status)
const data = series.map((v) => (v != null && Number.isFinite(v) ? Math.max(0, v) : 0))
const valid = data.filter((v) => Number.isFinite(v))
if (valid.length === 0) {
return (
<p className="text-xs text-muted-foreground">
Нет числовых точек RTT для графика (проба «{probeName}» {target}).
</p>
)
}
const chartPts = valid.length >= 2 ? data : [valid[0] ?? 0, valid[0] ?? 0]
const W = 720
const H = 168
const pad = { l: 48, r: 14, t: 14, b: 36 }
const iw = W - pad.l - pad.r
const ih = H - pad.t - pad.b
const maxVal = Math.max(...chartPts, 1)
const minVal = Math.min(...chartPts)
const span = Math.max(1, maxVal - minVal) * 1.08
const y0 = minVal - (span - (maxVal - minVal)) / 2
const y1 = y0 + span
const xAt = (i: number) => pad.l + (chartPts.length <= 1 ? iw / 2 : (i / (chartPts.length - 1)) * iw)
const yAt = (v: number) => pad.t + (1 - (v - y0) / span) * ih
const lineD = chartPts
.map((v, i) => `${i === 0 ? "M" : "L"}${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`)
.join(" ")
const areaD = `${lineD} L ${xAt(chartPts.length - 1).toFixed(1)},${pad.t + ih} L ${pad.l},${pad.t + ih} Z`
const gridVals = [0, 0.25, 0.5, 0.75, 1]
const fmt = (v: number) => `${Math.round(v)} мс`
return (
<div className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{probeName}</span>
<span className="font-mono ml-1.5">{target}</span>
</p>
<p className="text-[11px] text-muted-foreground">Ось X: старые замеры слева новые справа · обзор ~1 ч</p>
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-[720px] h-[min(200px,42vw)] min-h-[140px]"
style={{ display: "block" }}
preserveAspectRatio="xMidYMid meet"
>
{gridVals.map((g, i) => {
const y = pad.t + ih * (1 - g)
return (
<g key={i}>
<line
x1={pad.l}
x2={W - pad.r}
y1={y}
y2={y}
stroke="hsl(var(--border))"
strokeDasharray={g === 0 ? "0" : "2 5"}
/>
<text
x={pad.l - 8}
y={y + 4}
textAnchor="end"
fontSize="11"
fill="hsl(var(--muted-foreground))"
fontFamily="ui-monospace, monospace"
>
{fmt(y0 + span * g)}
</text>
</g>
)
})}
<path d={areaD} style={{ fill: stroke, fillOpacity: 0.12 }} />
<path
d={lineD}
fill="none"
style={{ stroke }}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
{[0, Math.floor((chartPts.length - 1) / 2), chartPts.length - 1]
.filter((i, idx, a) => a.indexOf(i) === idx)
.map((i) => (
<text
key={`x-${i}`}
x={xAt(i)}
y={H - 10}
textAnchor="middle"
fontSize="10"
fill="hsl(var(--muted-foreground))"
fontFamily="ui-monospace, monospace"
>
{i === chartPts.length - 1 ? "сейчас" : i === 0 ? "раньше" : "·"}
</text>
))}
</svg>
<div className="flex flex-wrap gap-4 text-[11px] text-muted-foreground">
<span>
min <span className="font-mono text-foreground">{Math.round(minVal)}</span> мс
</span>
<span>
max <span className="font-mono text-foreground">{Math.round(maxVal)}</span> мс
</span>
{valid.length < 2 && (
<span className="text-amber-600 dark:text-amber-400">В ряду одна точка линия для наглядности продублирована.</span>
)}
</div>
</div>
)
}
function probeGroupActionKey(srvId: string, group: { name: string; target: string }) {
return `${srvId}\t${group.name}\t${group.target}`
}
// ── shared components ──────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -313,6 +444,11 @@ function ServerPickerCards({
<span className="text-muted-foreground/30"></span>
<span className="truncate">{server.host}</span>
</div>
{!server.enabled && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400 leading-snug">
В инвентаре выключен для ping всё равно можно выбрать, если бекенд достигает REST API.
</p>
)}
</button>
)
})
@@ -553,6 +689,8 @@ function LinkedFilterPickerCards({
interface ServerResource {
serverId: string
/** false — в выбранном окне нет сэмплов ресурсов (не подменяем нулями «реальные» 0 %) */
hasData?: boolean
cpu: number
cpuHistory: number[]
ramUsed: number // MB
@@ -577,6 +715,24 @@ interface BackendServer {
os: string | null
}
function mapBackendServersToServers(data: BackendServer[]): Server[] {
return data.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: s.os ?? "—",
site: s.site || "—",
country: s.country || "UN",
asn: "",
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}))
}
interface SpeedTestRun {
id: string
startedAt: number
@@ -620,6 +776,25 @@ interface SpeedProbeRow {
lastPingError?: string | null
}
/** Сервер есть в БД speed-проб, но удалён из каталога — показываем группу без ломания списка */
function orphanSpeedSourceStub(id: string): Server {
return {
id,
name: `Нет в каталоге (#${id})`,
host: "—",
model: "—",
os: "—",
site: "—",
country: "UN",
asn: "",
type: "home-router",
enabled: false,
status: "offline",
latency: null,
sessions: 0,
}
}
function stripIpCidr(addr: string): string {
const t = addr.trim()
if (!t) return ""
@@ -663,7 +838,7 @@ function findLinkedSpeedProbe(
function fmtMB(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
return `${mb} МБ`
return `${mb.toFixed(1)} МБ`
}
function fmtUptime(sec: number): string {
@@ -714,6 +889,7 @@ const INIT_RESOURCES: ServerResource[] = mockServers.map(s => {
uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
boardName: BOARD_MAP[s.type] ?? "RouterBOARD",
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
hasData: true,
}
})
@@ -747,30 +923,36 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey;
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
function ResourcesTab({ resources, serversList }: { resources: ServerResource[]; serversList: Server[] }) {
function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
const [sortKey, setSortKey] = useState<ResSortKey>("name")
const [sortAsc, setSortAsc] = useState(true)
const [resSearch, setResSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
const rows = useMemo(() => resources.map(r => ({
...r,
server: serversList.find(s => s.id === r.serverId),
ramPct: Math.round(r.ramUsed / r.ramTotal * 100),
hddPct: Math.round(r.hddUsed / r.hddTotal * 100),
})).filter(r => r.server !== undefined), [resources, serversList])
const rows = useMemo(() => resources.map((r) => {
const hasData = r.hasData !== false
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
return {
...r,
hasData,
server: serversList.find(s => s.id === r.serverId),
ramPct,
hddPct,
}
}).filter(r => r.server !== undefined), [resources, serversList])
// KPI aggregates
const online = rows.filter(r => r.server!.status === "online")
const avgCpu = online.length ? Math.round(online.reduce((s, r) => s + r.cpu, 0) / online.length) : 0
const avgRam = online.length ? Math.round(online.reduce((s, r) => s + r.ramPct, 0) / online.length) : 0
const highCpu = rows.filter(r => r.server!.status === "online" && r.cpu >= 85).length
const highRam = rows.filter(r => r.server!.status === "online" && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server!.status === "online" && r.hddPct >= 85).length
// KPI aggregates (только серверы с реальными сэмплами за окно)
const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData)
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0
const highCpu = rows.filter(r => r.server!.status === "online" && r.hasData && r.cpu >= 85).length
const highRam = rows.filter(r => r.server!.status === "online" && r.hasData && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length
// Alerts
const alerts = useMemo(() =>
rows.filter(r => r.server!.status === "online" && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
rows.filter(r => r.server!.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
[rows],
)
@@ -998,7 +1180,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{visible.map(r => {
const srv = r.server!
const offline = srv.status !== "online"
const isCrit = !offline && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
const hasSamples = r.hasData !== false
const noMetrics = offline || !hasSamples
const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
return (
<tr key={r.serverId} className={cn(
@@ -1016,21 +1200,26 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
<span className="font-mono font-semibold">{srv.name}</span>
<TypeChip type={srv.type} />
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
{!offline && r.hasData === false && (
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
нет данных
</span>
)}
</div>
</td>
{/* Board + ROS */}
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-col leading-tight">
<span className="font-mono text-xs text-muted-foreground">{r.boardName}</span>
<span className="font-mono text-xs text-muted-foreground">{hasSamples ? r.boardName : "—"}</span>
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
</div>
</td>
{/* CPU */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></span>
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[140px]">
<div className="flex items-center gap-2">
@@ -1046,8 +1235,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{/* RAM */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></span>
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
@@ -1063,8 +1252,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{/* HDD */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></span>
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
@@ -1081,13 +1270,13 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{/* Uptime */}
<td className="px-4 py-3">
<span className="font-mono text-xs text-muted-foreground">
{offline ? "—" : fmtUptime(r.uptimeSeconds)}
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
</span>
</td>
{/* Temp */}
<td className="px-4 py-3">
{r.temp !== undefined && !offline ? (
{r.temp !== undefined && !noMetrics ? (
<span className={cn("font-mono text-sm font-semibold tabular-nums",
r.temp >= 70 ? "text-red-600 dark:text-red-400"
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
@@ -1108,7 +1297,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
</Card>
<p className="text-xs text-muted-foreground/40 text-center">
Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим
{liveApi
? "Автообновление каждые 5 сек (и кнопка «Обновить») · /system/resource via RouterOS REST API · backend"
: "Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим"}
</p>
</div>
)
@@ -1118,8 +1309,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
export default function UptimePage() {
const [allServers, setAllServers] = useState<Server[]>(mockServers)
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
const liveApi = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
@@ -1132,6 +1324,11 @@ export default function UptimePage() {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const [sheetOpen, setSheetOpen] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [uptimeRefreshBusy, setUptimeRefreshBusy] = useState(false)
/** Ключ — probeGroupActionKey: ручной ping группы «сервер + назначение». */
const [probeGroupPingBusy, setProbeGroupPingBusy] = useState<Record<string, boolean>>({})
/** Раскрытый подробный график RTT по id пробы */
const [probeRttChartOpen, setProbeRttChartOpen] = useState<Record<string, boolean>>({})
const [speedBusy, setSpeedBusy] = useState(false)
const [speedError, setSpeedError] = useState<string | null>(null)
const [speedRuns, setSpeedRuns] = useState<SpeedTestRun[]>([])
@@ -1153,37 +1350,18 @@ export default function UptimePage() {
})
useEffect(() => {
if (!isLive) {
if (!liveApi) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setAllServers(mockServers)
return
}
apiFetch<BackendServer[]>("/api/servers")
.then((data) => {
const mapped: Server[] = data.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: s.os ?? "—",
site: s.site || "—",
country: s.country || "UN",
asn: "",
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}))
setAllServers(mapped)
})
.catch(() => setAllServers([]))
}, [isLive, apiFetch])
/** В live каталог серверов подгружается вместе с overview (см. loadLiveOverview), иначе после «Серверы» строки ресурсов пропадают из-за .filter(r => r.server). */
}, [liveApi])
useEffect(() => {
if (isLive) return
if (liveApi) return
queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES)))
}, [isLive])
}, [liveApi])
const isPausedRef = useRef(isPaused)
useEffect(() => { isPausedRef.current = isPaused }, [isPaused])
@@ -1192,82 +1370,137 @@ export default function UptimePage() {
const overviewReqRef = useRef(0)
const loadLiveOverview = useCallback(async () => {
if (!isLive) return
if (!liveApi) return
const myReq = ++overviewReqRef.current
setOpError(null)
try {
const data = await apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h")
const [serverRows, data] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"),
apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"),
])
if (myReq !== overviewReqRef.current) return
setAllServers(mapBackendServersToServers(serverRows))
setProbes(data.probes)
setResources(data.resources)
} catch (e) {
if (myReq !== overviewReqRef.current) return
setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime")
}
}, [apiFetch, isLive])
}, [apiFetch, liveApi])
const reloadSpeedData = useCallback(async () => {
if (!liveApi) return
type RunRow = {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "done" | "error"
error?: string | null
srcAddress?: string | null
dstAddress?: string | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
createdAt: string
}
try {
const [sp, runsRes] = await Promise.all([
apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"),
apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"),
])
setSpeedProbes(sp.probes ?? [])
const rows = (runsRes.runs ?? []).map((r) => ({
id: r.id,
startedAt: Date.parse(r.createdAt),
srcServerId: r.srcServerId,
dstServerId: r.dstServerId,
srcInterface: r.srcInterface || undefined,
dstInterface: r.dstInterface || undefined,
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
status: (r.status === "error" ? "error" : "done") as "error" | "done",
command: "",
lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null,
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
}))
setSpeedRuns(rows)
} catch {
setSpeedProbes([])
setSpeedRuns([])
}
}, [apiFetch, liveApi])
const refreshUptimeLive = useCallback(async (opts?: { showSpinner?: boolean; pollDevices?: boolean }) => {
if (!liveApi) return
if (opts?.showSpinner) setUptimeRefreshBusy(true)
let collectErr: string | null = null
try {
void checkBackend()
if (opts?.pollDevices) {
try {
await apiFetch<{ ok: boolean; lastError?: string | null }>("/api/uptime/collect-now", { method: "POST" })
} catch (e) {
collectErr = e instanceof Error ? e.message : "Не удалось опросить устройства (collect-now)"
}
}
await Promise.all([loadLiveOverview(), reloadSpeedData()])
if (collectErr) {
setOpError((prev) => (prev ? `${prev} · ${collectErr}` : collectErr))
}
} finally {
if (opts?.showSpinner) setUptimeRefreshBusy(false)
}
}, [liveApi, loadLiveOverview, reloadSpeedData, checkBackend, apiFetch])
const refreshProbeGroupPings = useCallback(
async (srvId: string, group: { name: string; target: string; probes: PingProbe[] }) => {
if (!liveApi) return
const key = probeGroupActionKey(srvId, group)
setProbeGroupPingBusy((m) => ({ ...m, [key]: true }))
setOpError(null)
try {
await apiFetch<{ ok: boolean; polled: number }>("/api/uptime/probes/collect-group", {
method: "POST",
body: JSON.stringify({ probeIds: group.probes.map((p) => p.id) }),
})
await loadLiveOverview()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось выполнить ping группы")
} finally {
setProbeGroupPingBusy((m) => {
const next = { ...m }
delete next[key]
return next
})
}
},
[liveApi, apiFetch, loadLiveOverview],
)
useEffect(() => {
if (!isLive) return
queueMicrotask(() => { void loadLiveOverview() })
}, [isLive, loadLiveOverview])
if (!liveApi) return
queueMicrotask(() => { void refreshUptimeLive() })
}, [liveApi, refreshUptimeLive])
useEffect(() => {
if (!isLive) return
void apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes")
.then((data) => setSpeedProbes(data.probes ?? []))
.catch(() => setSpeedProbes([]))
}, [isLive, apiFetch])
useEffect(() => {
if (!isLive) return
void apiFetch<{
runs: Array<{
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "done" | "error"
error?: string | null
srcAddress?: string | null
dstAddress?: string | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
createdAt: string
}>
}>("/api/uptime/speed-test/runs")
.then((data) => {
const rows = (data.runs ?? []).map((r) => ({
id: r.id,
startedAt: Date.parse(r.createdAt),
srcServerId: r.srcServerId,
dstServerId: r.dstServerId,
srcInterface: r.srcInterface || undefined,
dstInterface: r.dstInterface || undefined,
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
status: (r.status === "error" ? "error" : "done") as "error" | "done",
command: "",
lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null,
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
}))
setSpeedRuns(rows)
})
.catch(() => setSpeedRuns([]))
}, [isLive, apiFetch])
if (!liveApi || isPaused) return
const id = setInterval(() => { void refreshUptimeLive() }, 5_000)
return () => clearInterval(id)
}, [liveApi, isPaused, refreshUptimeLive])
// add-probe form
const [newSrcId, setNewSrcId] = useState("")
@@ -1279,13 +1512,11 @@ export default function UptimePage() {
const [srcInterfaces, setSrcInterfaces] = useState<Array<{ name: string; running: boolean; disabled: boolean }>>([])
const [srcInterfacesBusy, setSrcInterfacesBusy] = useState(false)
const selectableSources = useMemo(
() => allServers.filter(s => s.enabled),
[allServers],
)
/** Источник для ping/speed: весь каталог (в т.ч. выключенные в inventory), иначе Home Router нельзя выбрать */
const selectableSources = useMemo(() => allServers, [allServers])
const loadSpeedInterfaces = useCallback(async (serverId: string) => {
if (!isLive) return
if (!liveApi) return
if (!serverId || speedIfaces[serverId]) return
const id = Number.parseInt(serverId, 10)
if (!Number.isFinite(id)) return
@@ -1295,7 +1526,7 @@ export default function UptimePage() {
} catch {
setSpeedIfaces((prev) => ({ ...prev, [serverId]: [] }))
}
}, [apiFetch, isLive, speedIfaces])
}, [apiFetch, liveApi, speedIfaces])
/** После загрузки списков интерфейсов сбросить выбор, если интерфейс не активен или отсутствует в списке */
useEffect(() => {
@@ -1333,7 +1564,7 @@ export default function UptimePage() {
setNewSrcInterface("")
return
}
if (!isLive) {
if (!liveApi) {
setSrcInterfaces([])
setNewSrcInterface("")
return
@@ -1356,22 +1587,23 @@ export default function UptimePage() {
setNewSrcInterface("")
})
.finally(() => setSrcInterfacesBusy(false))
}, [sheetOpen, newSrcId, isLive, apiFetch])
}, [sheetOpen, newSrcId, liveApi, apiFetch])
// servers that have at least one probe (preserve data-order)
const probedServerIds = useMemo(
() => [...new Set(probes.map(p => p.srcServerId))],
[probes],
)
const probedServers = useMemo(
() => allServers.filter(s => probedServerIds.includes(s.id)),
[probedServerIds, allServers],
)
const probedServers = useMemo(() => {
const inCatalog = allServers.filter((s) => probedServerIds.includes(s.id))
const orphanIds = probedServerIds.filter((id) => !inCatalog.some((s) => s.id === id))
return [...inCatalog, ...orphanIds.map(orphanSpeedSourceStub)]
}, [probedServerIds, allServers])
// live RTT tick
useEffect(() => {
const id = setInterval(() => {
if (isLive) return
if (liveApi) return
if (isPausedRef.current) return
setProbes(prev => prev.map(p => {
if (!p.enabled || p.status === "down" || p.rtt === null) return p
@@ -1381,16 +1613,17 @@ export default function UptimePage() {
}))
}, 3000)
return () => clearInterval(id)
}, [isLive])
}, [liveApi])
// live resource tick
useEffect(() => {
const id = setInterval(() => {
if (isLive) return
if (liveApi) return
if (isPausedRef.current) return
setResources(prev => prev.map(r => {
const srv = allServers.find(s => s.id === r.serverId)
if (!srv || srv.status !== "online") return r
if (r.hasData === false) return r
const newCpu = Math.min(99, Math.max(1, r.cpu + Math.round((Math.random() - 0.48) * 8)))
const newRam = Math.min(r.ramTotal - 64, Math.max(256, r.ramUsed + Math.round((Math.random() - 0.5) * 128)))
const newTemp = r.temp !== undefined
@@ -1407,7 +1640,7 @@ export default function UptimePage() {
}))
}, 5000)
return () => clearInterval(id)
}, [isLive, allServers])
}, [liveApi, allServers])
// ── derived ──
const stats = useMemo(() => ({
@@ -1420,7 +1653,7 @@ export default function UptimePage() {
const alertCount = useMemo(() =>
resources.filter(r => {
const s = allServers.find(x => x.id === r.serverId)
if (!s || s.status !== "online") return false
if (!s || s.status !== "online" || r.hasData === false) return false
const ramPct = Math.round(r.ramUsed / r.ramTotal * 100)
const hddPct = Math.round(r.hddUsed / r.hddTotal * 100)
return r.cpu >= 85 || ramPct >= 85 || hddPct >= 85 || (r.temp ?? 0) >= 70
@@ -1467,16 +1700,19 @@ export default function UptimePage() {
arr.push(p)
map.set(p.srcServerId, arr)
}
return selectableSources
.filter((s) => map.has(s.id))
.map((s) => ({
server: s,
probes: (map.get(s.id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)),
}))
}, [speedProbes, selectableSources])
const ids = [...map.keys()].sort((a, b) => {
const sa = allServers.find((s) => s.id === a) ?? orphanSpeedSourceStub(a)
const sb = allServers.find((s) => s.id === b) ?? orphanSpeedSourceStub(b)
return (sa.host + sa.name).localeCompare(sb.host + sb.name)
})
return ids.map((id) => ({
server: allServers.find((s) => s.id === id) ?? orphanSpeedSourceStub(id),
probes: (map.get(id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)),
}))
}, [speedProbes, allServers])
const persistProbes = useCallback((rows: PingProbe[]) => {
if (!isLive) return
if (!liveApi) return
void apiFetch("/api/uptime/probes", {
method: "PUT",
body: JSON.stringify({
@@ -1492,17 +1728,17 @@ export default function UptimePage() {
})),
}),
}).catch(() => {})
}, [apiFetch, isLive])
}, [apiFetch, liveApi])
const toggleDashboardStar = useCallback((id: string) => {
const cur = probes.find((p) => p.id === id)
if (!cur) return
const nextVal = !cur.showOnDashboard
if (isLive) {
if (liveApi) {
overviewReqRef.current += 1
}
setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p)))
if (isLive) {
if (liveApi) {
void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -1526,10 +1762,10 @@ export default function UptimePage() {
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
}
}
}, [probes, isLive, apiFetch, loadLiveOverview])
}, [probes, liveApi, apiFetch, loadLiveOverview])
const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => {
if (!isLive) return
if (!liveApi) return
void apiFetch("/api/uptime/speed-probes", {
method: "PUT",
body: JSON.stringify({
@@ -1551,7 +1787,7 @@ export default function UptimePage() {
})),
}),
}).catch(() => {})
}, [apiFetch, isLive])
}, [apiFetch, liveApi])
// ── actions ──
const toggleProbe = (id: string, v: boolean) =>
@@ -1562,7 +1798,7 @@ export default function UptimePage() {
})
const deleteProbe = (id: string) => {
if (!isLive) {
if (!liveApi) {
const s = readMockDashboardStarIds()
s.delete(id)
writeMockDashboardStarIds(s)
@@ -1747,7 +1983,7 @@ export default function UptimePage() {
lines: ["status: running..."],
}, ...prev].slice(0, 20))
try {
if (isLive) {
if (liveApi) {
const payload = {
runId,
probeId: probe.id,
@@ -1974,15 +2210,15 @@ export default function UptimePage() {
: <><PauseIcon className="size-4" />Пауза</>}
</Button>
<Button variant="outline" size="sm" onClick={() => {
if (isLive) {
void loadLiveOverview()
<Button variant="outline" size="sm" disabled={liveApi && uptimeRefreshBusy} onClick={() => {
if (liveApi) {
void refreshUptimeLive({ showSpinner: true, pollDevices: tab === "resources" || tab === "probes" })
return
}
setProbes(mockProbesWithSavedStars(INIT_PROBES))
setResources(INIT_RESOURCES)
}}>
<RefreshCwIcon className="size-4" />{isLive ? "Обновить" : "Сбросить"}
<RefreshCwIcon className={cn("size-4", uptimeRefreshBusy && "animate-spin")} />{liveApi ? "Обновить" : "Сбросить"}
</Button>
{tab === "probes" && (
@@ -2004,9 +2240,11 @@ export default function UptimePage() {
<div className="flex items-center gap-2 px-6 py-1.5 border-b shrink-0 text-[11px]"
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
<span className="size-1.5 rounded-full bg-[var(--status-online)] animate-pulse" />
{isLive
? "Live (backend)"
: "Live · проба обновляется каждые 3с · ресурсы каждые 5с"}
{liveApi
? (backendStatus === false
? "Live: /health не ответил — проверьте URL в настройках; запросы к API выполняются"
: "Live (backend)")
: "Демо-режим · пробы/ресурсы локальные; «Обновить» сбрасывает макет"}
</div>
)}
{isPaused && (
@@ -2066,7 +2304,7 @@ export default function UptimePage() {
<div className="flex-1 overflow-y-auto">
{/* ── resources tab ── */}
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} />}
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} liveApi={liveApi} />}
{/* ── speed tab ── */}
{tab === "speed" && (
@@ -2552,13 +2790,30 @@ export default function UptimePage() {
{!isCollapsed && (
<>
<div className="divide-y divide-border/60">
{probeGroups.map((group) => (
{probeGroups.map((group) => {
const groupBusyKey = probeGroupActionKey(srv.id, group)
const groupBusy = !!probeGroupPingBusy[groupBusyKey]
return (
<div key={`${group.name}|${group.target}`}>
<div className="px-4 py-2 border-b bg-muted/20 flex items-center gap-2 text-xs">
<ArrowRightIcon className="size-3.5 text-muted-foreground/60" />
<span className="font-medium truncate">{group.name}</span>
<span className="font-mono text-muted-foreground">{group.target}</span>
<span className="ml-auto text-muted-foreground">{group.probes.length} интерф.</span>
<ArrowRightIcon className="size-3.5 text-muted-foreground/60 shrink-0" />
<span className="font-medium truncate min-w-0">{group.name}</span>
<span className="font-mono text-muted-foreground shrink-0">{group.target}</span>
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="text-muted-foreground whitespace-nowrap">{group.probes.length} интерф.</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
disabled={!liveApi || groupBusy}
title={liveApi ? "Ping по всем интерфейсам группы и запись в БД" : "Доступно в режиме Live"}
onClick={() => { void refreshProbeGroupPings(srv.id, group) }}
>
<RefreshCwIcon className={cn("size-3", groupBusy && "animate-spin")} />
Обновить
</Button>
</div>
</div>
<div className="grid items-center gap-3 px-4 py-1.5 bg-muted/30 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
@@ -2579,7 +2834,8 @@ export default function UptimePage() {
{group.probes.map((p) => {
const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces)
return (
<div key={p.id}
<div key={p.id} className="border-b border-border/40 last:border-b-0">
<div
className={cn(
"grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
!p.enabled && "opacity-40",
@@ -2675,6 +2931,41 @@ export default function UptimePage() {
</Button>
</div>
</div>
<Collapsible
open={probeRttChartOpen[p.id] ?? false}
onOpenChange={(open) => setProbeRttChartOpen((m) => ({ ...m, [p.id]: open }))}
>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 border-t border-border/50 bg-muted/15 px-4 py-1.5 text-left text-xs text-muted-foreground",
"outline-none hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
)}
>
<ChevronDownIcon
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
probeRttChartOpen[p.id] && "rotate-180",
)}
/>
<span>
Подробный график RTT
<span className="font-mono tabular-nums text-muted-foreground/80 ml-1">
({p.series.length} точ.)
</span>
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/50 bg-muted/5 px-4 py-3">
<ProbePingRttDetailChart
series={p.series}
status={p.status}
probeName={p.name}
target={group.target}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
})}
@@ -2691,7 +2982,8 @@ export default function UptimePage() {
Добавить интерфейс для {group.name} ({group.target})
</button>
</div>
))}
)
})}
</div>
</>
)}
@@ -2894,9 +3186,12 @@ export default function UptimePage() {
</div>
</div>
<Field label="Источник (кто пингует)">
<Field
label="Источник (кто пингует)"
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
>
<ServerPickerCards
options={allServers.filter((s) => s.enabled)}
options={selectableSources}
selectedId={newSrcId}
onSelect={(id) => setNewSrcId(id)}
/>