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:
+2547
-149
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
@@ -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-пробы на сегментах WAN→JH (тот же формат бейджа, что на 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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user