Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e5fb065e2 | ||
|
|
5188b2aff2 | ||
|
|
cd1fd2c9d3 |
+317
-78
@@ -16,7 +16,10 @@ import {
|
||||
buildGreMapEdges,
|
||||
buildServerResourceMap,
|
||||
buildWanJhEdges,
|
||||
clipSegmentCircleToRect,
|
||||
computeNetworkMapLayout,
|
||||
MAP_SERVICE_NODE_H,
|
||||
MAP_SERVICE_NODE_W,
|
||||
NETWORK_MAP_H,
|
||||
NETWORK_MAP_LAYOUT_REVISION,
|
||||
NETWORK_MAP_PIPELINE_Y,
|
||||
@@ -52,7 +55,7 @@ import {
|
||||
matchNetflowForWan,
|
||||
type MatchedNetflowHop,
|
||||
} from "@/lib/map-netflow-hops"
|
||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
|
||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
||||
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
@@ -289,10 +292,106 @@ const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
|
||||
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
|
||||
]
|
||||
|
||||
const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
|
||||
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:google", bytes: 14_000_000, bps: 5_600_000 },
|
||||
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:google", bytes: 8_000_000, bps: 3_200_000 },
|
||||
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000 },
|
||||
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000 },
|
||||
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:aws", bytes: 9_000_000, bps: 3_600_000 },
|
||||
]
|
||||
|
||||
function servicePathKey(p: Pick<FlowMapServicePath, "clientId" | "viaId" | "enId" | "serviceId">): string {
|
||||
return `${p.clientId}|${p.viaId}|${p.enId}|${p.serviceId}`
|
||||
}
|
||||
|
||||
function greMatchesPath(e: GreMapEdge, h: { viaId: string; enId: string }): boolean {
|
||||
const ids = new Set([e.fromServer.id, e.toServer.id])
|
||||
return ids.has(h.viaId) && ids.has(h.enId)
|
||||
}
|
||||
|
||||
function serviceSharePct(share: number): string {
|
||||
return `${Math.round(share * 100)}%`
|
||||
}
|
||||
|
||||
function exitNodeIdsFromGre(greEdges: GreMapEdge[]): string[] {
|
||||
const ids = new Set<string>()
|
||||
for (const g of greEdges) {
|
||||
if (g.fromServer.type === "exit-node") ids.add(g.fromServer.id)
|
||||
if (g.toServer.type === "exit-node") ids.add(g.toServer.id)
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
function remapServiceEdgeFromId(
|
||||
fromId: string,
|
||||
servers: Server[],
|
||||
greEdges: GreMapEdge[],
|
||||
soleEnId: string | null,
|
||||
): string | null {
|
||||
const srv = servers.find((s) => s.id === fromId)
|
||||
if (srv?.type === "exit-node") return fromId
|
||||
for (const g of greEdges) {
|
||||
if (g.fromServer.id === fromId && g.toServer.type === "exit-node") return g.toServer.id
|
||||
if (g.toServer.id === fromId && g.fromServer.type === "exit-node") return g.fromServer.id
|
||||
}
|
||||
return soleEnId
|
||||
}
|
||||
|
||||
/** API-рёбра на EN; если hop нет — синтез от единственного EN на карте (GRE / каталог). */
|
||||
function drawableServiceEdges(
|
||||
services: FlowMapService[],
|
||||
edges: FlowMapServiceEdge[],
|
||||
servers: Server[],
|
||||
greEdges: GreMapEdge[],
|
||||
nodePosById: Record<string, { x: number; y: number }>,
|
||||
): FlowMapServiceEdge[] {
|
||||
const keep = new Set(services.map((s) => s.id))
|
||||
const greEn = exitNodeIdsFromGre(greEdges)
|
||||
const catalogEn = servers.filter((s) => s.type === "exit-node").map((s) => s.id)
|
||||
const enPool = greEn.length > 0 ? greEn : catalogEn
|
||||
const soleEnId = enPool.length === 1 ? enPool[0]! : null
|
||||
const merged = new Map<string, FlowMapServiceEdge>()
|
||||
|
||||
function bump(e: FlowMapServiceEdge) {
|
||||
const key = `${e.fromId}|${e.toId}`
|
||||
const prev = merged.get(key)
|
||||
if (!prev) {
|
||||
merged.set(key, { ...e })
|
||||
return
|
||||
}
|
||||
merged.set(key, {
|
||||
...prev,
|
||||
bytes: prev.bytes + e.bytes,
|
||||
bps: prev.bps + e.bps,
|
||||
bpsFwd: prev.bpsFwd + e.bpsFwd,
|
||||
bpsRev: prev.bpsRev + e.bpsRev,
|
||||
})
|
||||
}
|
||||
|
||||
for (const e of edges) {
|
||||
if (!keep.has(e.toId)) continue
|
||||
const fromId = remapServiceEdgeFromId(e.fromId, servers, greEdges, soleEnId)
|
||||
if (!fromId || !nodePosById[fromId]) continue
|
||||
bump({ ...e, fromId })
|
||||
}
|
||||
|
||||
const covered = new Set([...merged.values()].map((e) => e.toId))
|
||||
if (soleEnId && nodePosById[soleEnId]) {
|
||||
for (const svc of services) {
|
||||
if (covered.has(svc.id)) continue
|
||||
bump({
|
||||
fromId: soleEnId,
|
||||
toId: svc.id,
|
||||
bytes: svc.bytes,
|
||||
bps: svc.bps,
|
||||
bpsFwd: svc.bps,
|
||||
bpsRev: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...merged.values()]
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function pingColor(ms: number | null) {
|
||||
@@ -651,8 +750,8 @@ function ServiceNode({
|
||||
onClick: () => void
|
||||
onMouseDown: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
const bw = 86
|
||||
const bh = 58
|
||||
const bw = MAP_SERVICE_NODE_W
|
||||
const bh = MAP_SERVICE_NODE_H
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x},${y})`}
|
||||
@@ -698,6 +797,59 @@ function ServiceNode({
|
||||
)
|
||||
}
|
||||
|
||||
function ServicePathList({
|
||||
paths,
|
||||
servers,
|
||||
services,
|
||||
highlight,
|
||||
viaMode,
|
||||
onToggle,
|
||||
}: {
|
||||
paths: FlowMapServicePath[]
|
||||
servers: Server[]
|
||||
services: FlowMapService[]
|
||||
highlight: { viaId: string; enId: string; serviceId: string } | null
|
||||
viaMode: "via" | "service"
|
||||
onToggle: (p: FlowMapServicePath) => void
|
||||
}) {
|
||||
if (paths.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">—</p>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{paths.map((p) => {
|
||||
const rowKey = servicePathKey(p)
|
||||
const via = servers.find((s) => s.id === p.viaId)
|
||||
const viaLabel = via?.site || p.viaName
|
||||
const svc = services.find((s) => s.id === p.serviceId)
|
||||
const mid = viaMode === "via" ? viaLabel : (svc?.label ?? p.serviceId)
|
||||
const active = Boolean(
|
||||
highlight
|
||||
&& highlight.viaId === p.viaId
|
||||
&& highlight.enId === p.enId
|
||||
&& highlight.serviceId === p.serviceId,
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={rowKey}
|
||||
type="button"
|
||||
onClick={() => onToggle(p)}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
|
||||
active ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono truncate min-w-0">{p.clientName} · {mid}</span>
|
||||
<span className="font-mono text-emerald-400 tabular-nums shrink-0">
|
||||
{formatNetflowRate({ bytes: p.bytes, bps: p.bps, bpsFwd: p.bps, bpsRev: 0 })}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
|
||||
x: number; y: number
|
||||
wan: { name: string; isp: string; maxDl: number; maxUl: number }
|
||||
@@ -938,6 +1090,7 @@ export default function NetworkMapPage() {
|
||||
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
|
||||
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
|
||||
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
|
||||
const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([])
|
||||
const [mapSharePct, setMapSharePct] = useState(5)
|
||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||
@@ -1033,6 +1186,7 @@ export default function NetworkMapPage() {
|
||||
setMapHops([])
|
||||
setMapServices(MOCK_MAP_SERVICES)
|
||||
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
|
||||
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
|
||||
setMapSharePct(5)
|
||||
setDataError(null)
|
||||
})
|
||||
@@ -1062,6 +1216,7 @@ export default function NetworkMapPage() {
|
||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||
const [selected, setSelected] = useState<Server | null>(null)
|
||||
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
|
||||
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
|
||||
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
|
||||
@@ -1110,6 +1265,7 @@ export default function NetworkMapPage() {
|
||||
setMapHops([])
|
||||
setMapServices(MOCK_MAP_SERVICES)
|
||||
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
|
||||
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
|
||||
setMapSharePct(5)
|
||||
})
|
||||
return
|
||||
@@ -1119,6 +1275,7 @@ export default function NetworkMapPage() {
|
||||
setMapHops([])
|
||||
setMapServices([])
|
||||
setMapServiceEdges([])
|
||||
setMapServicePaths([])
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1133,6 +1290,7 @@ export default function NetworkMapPage() {
|
||||
setMapHops(res.hops ?? [])
|
||||
setMapServices(res.services ?? [])
|
||||
setMapServiceEdges(res.serviceEdges ?? [])
|
||||
setMapServicePaths(res.servicePaths ?? [])
|
||||
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -1337,9 +1495,9 @@ export default function NetworkMapPage() {
|
||||
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
|
||||
|
||||
const visibleMapServices = showServices ? mapServices : []
|
||||
const visibleServiceEdges = showServices ? mapServiceEdges.filter((e) =>
|
||||
visibleMapServices.some((s) => s.id === e.toId),
|
||||
) : []
|
||||
const visibleServiceEdges = showServices
|
||||
? drawableServiceEdges(visibleMapServices, mapServiceEdges, mapServers, greEdges, nodePosById)
|
||||
: []
|
||||
|
||||
const nodes = mapServers
|
||||
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
|
||||
@@ -1440,7 +1598,13 @@ export default function NetworkMapPage() {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
|
||||
if (e.key === "Escape") {
|
||||
setSelected(null)
|
||||
setSelWanIdx(null)
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
}
|
||||
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
||||
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
||||
if (e.key === "0" || e.key.toLowerCase() === "f") fitView()
|
||||
@@ -1531,7 +1695,13 @@ export default function NetworkMapPage() {
|
||||
const moved = dragRef.current?.moved ?? false
|
||||
dragRef.current = null
|
||||
setIsDragging(false)
|
||||
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
|
||||
if (!moved) {
|
||||
setSelected(null)
|
||||
setSelWanIdx(null)
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Node drag start ──────────────────────────────────────────────────────
|
||||
@@ -1568,6 +1738,7 @@ export default function NetworkMapPage() {
|
||||
function selectServer(s: Server) {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
setSelected(prev => prev?.id === s.id ? null : s)
|
||||
setSelWanIdx(null)
|
||||
setHoveredId(null)
|
||||
@@ -1576,15 +1747,28 @@ export default function NetworkMapPage() {
|
||||
setSelectedGreEdge(null)
|
||||
setSelected(null)
|
||||
setSelWanIdx(null)
|
||||
setHighlightedPath(null)
|
||||
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
|
||||
}
|
||||
function selectWan(s: Server, wanIdx: number) {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
setSelected(s)
|
||||
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
|
||||
}
|
||||
|
||||
function togglePathHighlight(p: FlowMapServicePath) {
|
||||
setHighlightedPath((prev) => (
|
||||
prev
|
||||
&& prev.viaId === p.viaId
|
||||
&& prev.enId === p.enId
|
||||
&& prev.serviceId === p.serviceId
|
||||
? null
|
||||
: { viaId: p.viaId, enId: p.enId, serviceId: p.serviceId }
|
||||
))
|
||||
}
|
||||
|
||||
function openWanJhSpeedDetail(ev: React.MouseEvent<SVGElement>, edge: WanJhEdge) {
|
||||
ev.stopPropagation()
|
||||
const home = mapServers.find((s) => s.id === edge.homeId)
|
||||
@@ -1879,13 +2063,23 @@ export default function NetworkMapPage() {
|
||||
setSelectedGreEdge(e)
|
||||
setSelected(null)
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
setSelWanIdx(null)
|
||||
}
|
||||
return (
|
||||
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
|
||||
<g
|
||||
key={edgeId}
|
||||
opacity={dimmed ? 0.05 : (highlightedPath && !greMatchesPath(e, highlightedPath) ? 0.14 : 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={hopHasRate(flowHop) ? 2.6 : 1.5}
|
||||
stroke={ts.stroke}
|
||||
strokeWidth={
|
||||
highlightedPath && greMatchesPath(e, highlightedPath)
|
||||
? 3.4
|
||||
: hopHasRate(flowHop) ? 2.6 : 1.5
|
||||
}
|
||||
strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
|
||||
opacity={ts.opacity}
|
||||
/>
|
||||
@@ -2019,55 +2213,6 @@ export default function NetworkMapPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── EN → destination services ── */}
|
||||
{visibleServiceEdges.map((edge) => {
|
||||
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
||||
const to = servicePosById[edge.toId]
|
||||
if (!from || !to) return null
|
||||
const hop: MatchedNetflowHop = {
|
||||
bytes: edge.bytes,
|
||||
bps: edge.bps,
|
||||
bpsFwd: edge.bpsFwd,
|
||||
bpsRev: edge.bpsRev,
|
||||
}
|
||||
const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16)
|
||||
const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId
|
||||
const svc = visibleMapServices.find((s) => s.id === edge.toId)
|
||||
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
|
||||
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
|
||||
const pathTitle = `${clientLabel} → ${enName} → ${svc?.label ?? edge.toId}`
|
||||
return (
|
||||
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
|
||||
<title>{pathTitle}</title>
|
||||
<line
|
||||
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={hopHasRate(hop) ? 2.2 : 1.3}
|
||||
strokeDasharray="5 5"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{showAnimDots && hopHasRate(hop) && (
|
||||
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
|
||||
<animateMotion dur="2.6s" repeatCount="indefinite"
|
||||
path={`M ${from.x} ${from.y} L ${to.x} ${to.y}`} />
|
||||
</circle>
|
||||
)}
|
||||
{hopHasRate(hop) && (
|
||||
<NetflowRateBadge
|
||||
mx={mx}
|
||||
my={my}
|
||||
hop={hop}
|
||||
onOpen={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const svc = visibleMapServices.find((s) => s.id === edge.toId)
|
||||
if (svc) selectService(svc)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Server nodes ── */}
|
||||
{nodes.map(n => (
|
||||
<ServerNode
|
||||
@@ -2136,6 +2281,90 @@ export default function NetworkMapPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── EN → destination services (поверх узлов, чтобы пунктир не прятался) ── */}
|
||||
{visibleServiceEdges.map((edge) => {
|
||||
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
||||
const to = servicePosById[edge.toId]
|
||||
if (!from || !to) return null
|
||||
const hop: MatchedNetflowHop = {
|
||||
bytes: edge.bytes,
|
||||
bps: edge.bps,
|
||||
bpsFwd: edge.bpsFwd,
|
||||
bpsRev: edge.bpsRev,
|
||||
}
|
||||
const fromR = "type" in from && from.type
|
||||
? TYPE_STYLE[from.type].r
|
||||
: TYPE_STYLE["exit-node"].r
|
||||
const clipped = clipSegmentCircleToRect(
|
||||
from.x,
|
||||
from.y,
|
||||
fromR,
|
||||
to.x,
|
||||
to.y,
|
||||
MAP_SERVICE_NODE_W / 2,
|
||||
MAP_SERVICE_NODE_H / 2,
|
||||
)
|
||||
const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16)
|
||||
const pathHit = Boolean(
|
||||
highlightedPath
|
||||
&& highlightedPath.enId === edge.fromId
|
||||
&& highlightedPath.serviceId === edge.toId,
|
||||
)
|
||||
const pathDim = Boolean(highlightedPath) && !pathHit
|
||||
const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId))
|
||||
const svc = visibleMapServices.find((s) => s.id === edge.toId)
|
||||
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
|
||||
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
|
||||
const pathTitle = `${clientLabel} → ${enName} → ${svc?.label ?? edge.toId}`
|
||||
return (
|
||||
<g
|
||||
key={`${edge.fromId}|${edge.toId}`}
|
||||
opacity={pathDim ? 0.12 : hl ? 1 : 0.72}
|
||||
style={{ transition: "opacity 0.3s" }}
|
||||
>
|
||||
<title>{pathTitle}</title>
|
||||
<line
|
||||
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={pathHit ? 3.2 : hopHasRate(hop) ? 2.4 : 1.4}
|
||||
strokeDasharray="6 5"
|
||||
opacity="0.85"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<line
|
||||
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
|
||||
stroke="#00000000"
|
||||
strokeWidth={14}
|
||||
strokeLinecap="round"
|
||||
style={{ cursor: "pointer" }}
|
||||
onPointerDown={(ev) => { ev.stopPropagation() }}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
if (svc) selectService(svc)
|
||||
}}
|
||||
/>
|
||||
{showAnimDots && hopHasRate(hop) && (
|
||||
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
|
||||
<animateMotion dur="2.6s" repeatCount="indefinite"
|
||||
path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} />
|
||||
</circle>
|
||||
)}
|
||||
{hopHasRate(hop) && (
|
||||
<NetflowRateBadge
|
||||
mx={mx}
|
||||
my={my}
|
||||
hop={hop}
|
||||
onOpen={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const hit = visibleMapServices.find((s) => s.id === edge.toId)
|
||||
if (hit) selectService(hit)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Hover tooltip ── */}
|
||||
{hoveredNode && !isDragging && (
|
||||
<SvgTooltip n={hoveredNode} />
|
||||
@@ -2528,25 +2757,18 @@ export default function NetworkMapPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Клиенты</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{(() => {
|
||||
const names = new Map<string, string>()
|
||||
for (const e of visibleServiceEdges.filter((x) => x.toId === selectedService.id)) {
|
||||
if (e.clients?.length) {
|
||||
for (const c of e.clients) names.set(c.id, c.name)
|
||||
} else if (e.clientName) {
|
||||
names.set(e.clientId ?? e.clientName, e.clientName)
|
||||
}
|
||||
}
|
||||
if (names.size === 0) {
|
||||
return <p className="text-xs text-muted-foreground">—</p>
|
||||
}
|
||||
return [...names.values()].map((name) => (
|
||||
<p key={name} className="text-xs font-mono truncate">{name}</p>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
|
||||
<ServicePathList
|
||||
paths={mapServicePaths
|
||||
.filter((p) => p.serviceId === selectedService.id)
|
||||
.slice()
|
||||
.sort((a, b) => b.bps - a.bps)}
|
||||
servers={mapServers}
|
||||
services={mapServices}
|
||||
highlight={highlightedPath}
|
||||
viaMode="via"
|
||||
onToggle={togglePathHighlight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -2775,6 +2997,23 @@ export default function NetworkMapPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selected.type === "jump-host" || selected.type === "exit-node") && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
|
||||
<ServicePathList
|
||||
paths={mapServicePaths
|
||||
.filter((p) => p.viaId === selected.id || p.enId === selected.id)
|
||||
.slice()
|
||||
.sort((a, b) => b.bps - a.bps)}
|
||||
servers={mapServers}
|
||||
services={mapServices}
|
||||
highlight={highlightedPath}
|
||||
viaMode="service"
|
||||
onToggle={togglePathHighlight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources */}
|
||||
{(() => {
|
||||
const res = srvResMap[selected.id]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-hardening.test.ts && tsx src/services/traffic-flow-purge.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-hardening.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/sqlite-write-opt.test.ts",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+68
-1
@@ -8,18 +8,85 @@ import { env } from "../config.js"
|
||||
import * as schema from "./schema.js"
|
||||
|
||||
export const SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||
/** ~16 MiB page cache (negative = KiB). */
|
||||
export const SQLITE_CACHE_SIZE_KIB = 16_000
|
||||
export const SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000
|
||||
|
||||
export interface SqliteWriteStats {
|
||||
insert: number
|
||||
update: number
|
||||
delete: number
|
||||
walCheckpoint: number
|
||||
}
|
||||
|
||||
let writeTrace: SqliteWriteStats | null = null
|
||||
|
||||
function classifyWriteSql(sql: string): keyof Omit<SqliteWriteStats, "walCheckpoint"> | null {
|
||||
const head = sql.trimStart().slice(0, 12).toUpperCase()
|
||||
if (head.startsWith("INSERT")) return "insert"
|
||||
if (head.startsWith("UPDATE")) return "update"
|
||||
if (head.startsWith("DELETE")) return "delete"
|
||||
return null
|
||||
}
|
||||
|
||||
function installSqliteWriteTrace(handle: SqliteHandle): SqliteHandle {
|
||||
const origPrepare = handle.prepare.bind(handle)
|
||||
handle.prepare = ((sql: string) => {
|
||||
const stmt = origPrepare(sql)
|
||||
const kind = classifyWriteSql(sql)
|
||||
if (!kind) return stmt
|
||||
const origRun = stmt.run.bind(stmt)
|
||||
stmt.run = ((...args: unknown[]) => {
|
||||
if (writeTrace) writeTrace[kind] += 1
|
||||
return origRun(...args)
|
||||
}) as typeof stmt.run
|
||||
return stmt
|
||||
}) as typeof handle.prepare
|
||||
|
||||
const origExec = handle.exec.bind(handle)
|
||||
handle.exec = ((sql: string) => {
|
||||
if (writeTrace) {
|
||||
for (const part of sql.split(";")) {
|
||||
const kind = classifyWriteSql(part)
|
||||
if (kind) writeTrace[kind] += 1
|
||||
}
|
||||
}
|
||||
return origExec(sql)
|
||||
}) as typeof handle.exec
|
||||
|
||||
const origPragma = handle.pragma.bind(handle)
|
||||
handle.pragma = ((source: string, options?: { simple?: boolean }) => {
|
||||
if (writeTrace && /wal_checkpoint/i.test(source)) writeTrace.walCheckpoint += 1
|
||||
return origPragma(source, options as never)
|
||||
}) as typeof handle.pragma
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
export function countSqliteWrites<T>(fn: () => T): { result: T; stats: SqliteWriteStats } {
|
||||
const stats: SqliteWriteStats = { insert: 0, update: 0, delete: 0, walCheckpoint: 0 }
|
||||
writeTrace = stats
|
||||
try {
|
||||
return { result: fn(), stats }
|
||||
} finally {
|
||||
writeTrace = null
|
||||
}
|
||||
}
|
||||
|
||||
export function applySqlitePragmas(handle: SqliteHandle): void {
|
||||
handle.pragma("journal_mode = WAL")
|
||||
handle.pragma("foreign_keys = ON")
|
||||
handle.pragma(`busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`)
|
||||
handle.pragma("synchronous = NORMAL")
|
||||
handle.pragma(`wal_autocheckpoint = ${SQLITE_WAL_AUTOCHECKPOINT_PAGES}`)
|
||||
handle.pragma("temp_store = MEMORY")
|
||||
handle.pragma(`cache_size = -${SQLITE_CACHE_SIZE_KIB}`)
|
||||
}
|
||||
|
||||
function openSqlite(): SqliteHandle {
|
||||
const handle = new Database(env.DATABASE_PATH)
|
||||
applySqlitePragmas(handle)
|
||||
return handle
|
||||
return installSqliteWriteTrace(handle)
|
||||
}
|
||||
|
||||
let sqlite = openSqlite()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { serverSnapshots, servers } from "../../../db/schema.js"
|
||||
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||
|
||||
export type ServerRow = typeof servers.$inferSelect
|
||||
export type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||
@@ -17,6 +18,7 @@ export function createServerRow(
|
||||
values: Omit<typeof servers.$inferInsert, "id">,
|
||||
): ServerRow {
|
||||
const [inserted] = db.insert(servers).values(values).returning().all()
|
||||
invalidateFlowCatalogCache()
|
||||
return inserted
|
||||
}
|
||||
|
||||
@@ -25,11 +27,13 @@ export function updateServerRowById(
|
||||
values: Partial<ServerRow>,
|
||||
): ServerRow {
|
||||
const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all()
|
||||
invalidateFlowCatalogCache()
|
||||
return updated
|
||||
}
|
||||
|
||||
export function deleteServerRowById(id: number): void {
|
||||
db.delete(servers).where(eq(servers.id, id)).run()
|
||||
invalidateFlowCatalogCache()
|
||||
}
|
||||
|
||||
export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { and, count, eq } from "drizzle-orm"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
|
||||
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||
|
||||
export type AppUserRow = typeof appUsers.$inferSelect
|
||||
export type BindingRow = typeof userInterfaceBindings.$inferSelect
|
||||
@@ -19,6 +20,7 @@ export function getUserRowByLogin(login: string): AppUserRow | undefined {
|
||||
|
||||
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
|
||||
const [inserted] = db.insert(appUsers).values(values).returning().all()
|
||||
invalidateFlowCatalogCache()
|
||||
return inserted
|
||||
}
|
||||
|
||||
@@ -27,11 +29,13 @@ export function updateUserRowById(
|
||||
values: Partial<AppUserRow>,
|
||||
): AppUserRow {
|
||||
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
|
||||
invalidateFlowCatalogCache()
|
||||
return updated
|
||||
}
|
||||
|
||||
export function deleteUserRowById(id: string): void {
|
||||
db.delete(appUsers).where(eq(appUsers.id, id)).run()
|
||||
invalidateFlowCatalogCache()
|
||||
}
|
||||
|
||||
export function listBindingRows(): BindingRow[] {
|
||||
@@ -65,13 +69,15 @@ export function getBindingByServerIfacePeer(
|
||||
|
||||
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
|
||||
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
|
||||
invalidateFlowCatalogCache()
|
||||
return inserted
|
||||
}
|
||||
|
||||
export function deleteBindingRowById(id: string): void {
|
||||
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
|
||||
invalidateFlowCatalogCache()
|
||||
}
|
||||
|
||||
export function countUserRows(): number {
|
||||
return db.select().from(appUsers).all().length
|
||||
return db.select({ n: count() }).from(appUsers).all()[0]?.n ?? 0
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { count } from "drizzle-orm"
|
||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
||||
import { db } from "../db/index.js"
|
||||
@@ -11,27 +12,22 @@ import {
|
||||
} from "../db/schema.js"
|
||||
import { listUsers } from "../modules/users/service/users-service.js"
|
||||
|
||||
function tableCount(table: typeof servers | typeof filterRules | typeof uptimeProbes | typeof uptimeSpeedProbes | typeof recursiveRoutes): number {
|
||||
return db.select({ n: count() }).from(table).all()[0]?.n ?? 0
|
||||
}
|
||||
|
||||
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/sidebar-counts", async (_req, reply) => {
|
||||
const [
|
||||
serversTotal,
|
||||
filterRulesTotal,
|
||||
uptimeProbesTotal,
|
||||
uptimeSpeedProbesTotal,
|
||||
recursiveRoutesTotal,
|
||||
certificatesTotal,
|
||||
wireguardTotal,
|
||||
usersTotal,
|
||||
] = await Promise.all([
|
||||
Promise.resolve(db.select().from(servers).all().length),
|
||||
Promise.resolve(db.select().from(filterRules).all().length),
|
||||
Promise.resolve(db.select().from(uptimeProbes).all().length),
|
||||
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
|
||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
||||
const serversTotal = tableCount(servers)
|
||||
const filterRulesTotal = tableCount(filterRules)
|
||||
const uptimeProbesTotal = tableCount(uptimeProbes)
|
||||
const uptimeSpeedProbesTotal = tableCount(uptimeSpeedProbes)
|
||||
const recursiveRoutesTotal = tableCount(recursiveRoutes)
|
||||
const [certificatesTotal, wireguardTotal] = await Promise.all([
|
||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||
countWireGuardInterfaces().catch(() => 0),
|
||||
Promise.resolve(listUsers().length),
|
||||
])
|
||||
const usersTotal = listUsers().length
|
||||
|
||||
return reply.send({
|
||||
servers: serversTotal,
|
||||
|
||||
@@ -37,11 +37,12 @@ export function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
if (existing) {
|
||||
if (existing.payloadJson === payloadJson) return
|
||||
db.update(alertEnginePrevLive)
|
||||
.set({ payloadJson, updatedAt: new Date().toISOString() })
|
||||
.where(eq(alertEnginePrevLive.kind, kind))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
|
||||
return
|
||||
}
|
||||
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { db, sqliteDatabase } from "../db/index.js"
|
||||
import { alertBgpPeerSamples, alertGreTunnelSamples } from "../db/schema.js"
|
||||
import { bgpPeerAlertKey, fetchBgpSessionsForAlerts } from "./bgp-peers-live.js"
|
||||
import { fetchGreTunnelLiveRows } from "./gre-tunnels-live.js"
|
||||
import type { GreBgpSnapshotRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
@@ -12,8 +10,8 @@ export function getGreBgpSnapshotCollectorState(): { running: boolean } {
|
||||
}
|
||||
|
||||
/**
|
||||
* Один опрос GRE + BGP по включённым серверам и запись строк в SQLite для `buildSignalSnapshot`.
|
||||
* Движок оповещений больше не дублирует эти REST-запросы.
|
||||
* Один опрос GRE + BGP по включённым серверам.
|
||||
* Снимок для алертов живёт в `scheduler_runs.result_json` (`greTunnels` / `bgpPeers`).
|
||||
*/
|
||||
export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnapshot> {
|
||||
const sampledAt = new Date().toISOString()
|
||||
@@ -62,32 +60,8 @@ export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnap
|
||||
const bgpRows = bgpSettled.status === "fulfilled" ? bgpSettled.value : []
|
||||
snapshot.greTunnels = greRows.map((r) => ({ targetLabel: r.targetLabel, status: r.status }))
|
||||
snapshot.bgpPeers = bgpRows.map((s) => ({ key: bgpPeerAlertKey(s), state: s.state }))
|
||||
|
||||
db.transaction((tx) => {
|
||||
for (const r of greRows) {
|
||||
tx.insert(alertGreTunnelSamples).values({
|
||||
sampledAt,
|
||||
targetLabel: r.targetLabel,
|
||||
status: r.status,
|
||||
}).run()
|
||||
snapshot.greWritten += 1
|
||||
}
|
||||
for (const s of bgpRows) {
|
||||
tx.insert(alertBgpPeerSamples).values({
|
||||
sampledAt,
|
||||
peerKey: bgpPeerAlertKey(s),
|
||||
state: s.state,
|
||||
}).run()
|
||||
snapshot.bgpWritten += 1
|
||||
}
|
||||
})
|
||||
|
||||
sqliteDatabase
|
||||
.prepare(`DELETE FROM alert_gre_tunnel_samples WHERE sampled_at < datetime('now', '-30 days')`)
|
||||
.run()
|
||||
sqliteDatabase
|
||||
.prepare(`DELETE FROM alert_bgp_peer_samples WHERE sampled_at < datetime('now', '-30 days')`)
|
||||
.run()
|
||||
snapshot.greWritten = greRows.length
|
||||
snapshot.bgpWritten = bgpRows.length
|
||||
|
||||
if (errors.length) snapshot.errors = errors
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SnapshotInsert } from "../db/schema.js"
|
||||
import type { SnapshotRead } from "../types/server.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import { parseRosCpuLoadPercent, parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
||||
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
|
||||
|
||||
// ── pollServer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,11 +65,13 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
|
||||
rawIpAddresses: JSON.stringify(addresses),
|
||||
} satisfies Partial<SnapshotInsert>)
|
||||
|
||||
// Keep server.name in sync with RouterOS identity
|
||||
db.update(servers)
|
||||
.set({ name: identity.name, updatedAt: now })
|
||||
.where(eq(servers.id, serverId))
|
||||
.run()
|
||||
if ((identity.name || "") !== (server.name || "")) {
|
||||
db.update(servers)
|
||||
.set({ name: identity.name, updatedAt: now })
|
||||
.where(eq(servers.id, serverId))
|
||||
.run()
|
||||
invalidateFlowCatalogCache()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// Log but don't throw — we still persist the offline snapshot
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 3. `uptime_speed` — ниже (BW-test, тяжёлый); локи узлов — `withBtestNodeLocks` в speed-сервисе.
|
||||
*
|
||||
* **Оповещения (`alert_engine`):** читают SQLite после джоб сбора (см. `buildSignalSnapshot`), в т.ч.
|
||||
* `gre_bgp` → `alert_gre_tunnel_samples` / `alert_bgp_peer_samples`. После успешного завершения джоб
|
||||
* `gre_bgp` → snapshot в `scheduler_runs.result_json` (`greTunnels` / `bgpPeers`). После успешного завершения джоб
|
||||
* `traffic`, `uptime_*`, `servers_rest_ping`, `gre_bgp` планируется **дополнительный** прогон движка
|
||||
* (debounce), см. [`alert-collector-hooks.ts`](./alert-collector-hooks.ts); любой другой писатель сэмплов
|
||||
* для снимка оповещений тоже должен вызывать `scheduleAlertEngineAfterDataCollectors()` после коммита.
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import { desc, eq, lt } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { schedulerRuns } from "../db/schema.js"
|
||||
import { events, schedulerRuns } from "../db/schema.js"
|
||||
import type { SchedulerRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||
import {
|
||||
@@ -71,6 +71,20 @@ const timers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
|
||||
const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
const QUIET_SCHEDULER_OK_JOBS = new Set<SchedulerJobKey>([
|
||||
"traffic",
|
||||
"servers_rest_ping",
|
||||
"uptime_resources",
|
||||
"uptime_ping",
|
||||
"uptime_speed",
|
||||
"gre_bgp",
|
||||
"alert_engine",
|
||||
])
|
||||
|
||||
export function shouldAppendSchedulerOkEvent(jobKey: SchedulerJobKey): boolean {
|
||||
return !QUIET_SCHEDULER_OK_JOBS.has(jobKey)
|
||||
}
|
||||
|
||||
function newRunId(): string {
|
||||
return `sch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
@@ -84,18 +98,21 @@ function appendSchedulerRun(row: {
|
||||
durationMs: number
|
||||
result?: SchedulerRunSnapshot | null
|
||||
}) {
|
||||
db.insert(schedulerRuns).values({
|
||||
id: newRunId(),
|
||||
jobKey: row.jobKey,
|
||||
startedAt: row.startedAt,
|
||||
finishedAt: row.finishedAt,
|
||||
status: row.status,
|
||||
error: row.error,
|
||||
durationMs: row.durationMs,
|
||||
resultJson: row.result ? JSON.stringify(row.result) : null,
|
||||
}).run()
|
||||
const cutoff = new Date(Date.now() - RUN_LOG_RETENTION_MS).toISOString()
|
||||
db.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff)).run()
|
||||
db.transaction((tx) => {
|
||||
tx.insert(schedulerRuns).values({
|
||||
id: newRunId(),
|
||||
jobKey: row.jobKey,
|
||||
startedAt: row.startedAt,
|
||||
finishedAt: row.finishedAt,
|
||||
status: row.status,
|
||||
error: row.error,
|
||||
durationMs: row.durationMs,
|
||||
resultJson: row.result ? JSON.stringify(row.result) : null,
|
||||
}).run()
|
||||
tx.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff)).run()
|
||||
tx.delete(events).where(lt(events.createdAt, cutoff)).run()
|
||||
})
|
||||
}
|
||||
|
||||
async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
||||
@@ -159,19 +176,21 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
||||
durationMs: Date.now() - startedAt,
|
||||
result: snapshot ?? null,
|
||||
})
|
||||
appendEvent({
|
||||
level: "info",
|
||||
eventType: "scheduler.job.ok",
|
||||
sourceModule: "scheduler",
|
||||
title: "Задача планировщика завершена",
|
||||
message: `${jobKey}: выполнено за ${Date.now() - startedAt} мс`,
|
||||
entityType: "job",
|
||||
entityId: jobKey,
|
||||
payload: {
|
||||
startedAt: startedIso,
|
||||
finishedAt: finishedIso,
|
||||
},
|
||||
})
|
||||
if (shouldAppendSchedulerOkEvent(jobKey)) {
|
||||
appendEvent({
|
||||
level: "info",
|
||||
eventType: "scheduler.job.ok",
|
||||
sourceModule: "scheduler",
|
||||
title: "Задача планировщика завершена",
|
||||
message: `${jobKey}: выполнено за ${Date.now() - startedAt} мс`,
|
||||
entityType: "job",
|
||||
entityId: jobKey,
|
||||
payload: {
|
||||
startedAt: startedIso,
|
||||
finishedAt: finishedIso,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (
|
||||
jobKey === "traffic" ||
|
||||
jobKey === "servers_rest_ping" ||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { countSqliteWrites, sqliteDatabase } from "../db/index.js"
|
||||
import { events } from "../db/schema.js"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
attachEngineSqlite,
|
||||
bumpPacketMeta,
|
||||
configureEngine,
|
||||
flushPending,
|
||||
ingestParsedFlowsForServerForTests,
|
||||
resetEngineForTests,
|
||||
setEngineError,
|
||||
} from "./traffic-flow-engine.js"
|
||||
import { collectGreBgpSnapshotOnce } from "./gre-bgp-snapshot-collector.js"
|
||||
import { shouldAppendSchedulerOkEvent, executeSchedulerJob } from "./scheduler.js"
|
||||
import { savePrevLiveMap, loadPrevLiveMap } from "./alert-engine/prev-live-store.js"
|
||||
import {
|
||||
invalidateFlowCatalogCache,
|
||||
loadFlowTopology,
|
||||
seedFlowTopologyForTests,
|
||||
} from "./traffic-flow-topology.js"
|
||||
|
||||
resetEngineForTests()
|
||||
attachEngineSqlite(sqliteDatabase)
|
||||
seedFlowTopologyForTests(null)
|
||||
invalidateFlowCatalogCache()
|
||||
|
||||
const idle1 = countSqliteWrites(() => {
|
||||
flushPending()
|
||||
})
|
||||
assert.equal(idle1.stats.walCheckpoint, 0)
|
||||
assert.ok(idle1.stats.update >= 1, "first idle flush persists listener stats")
|
||||
|
||||
const idle2 = countSqliteWrites(() => {
|
||||
flushPending()
|
||||
})
|
||||
assert.equal(idle2.stats.update, 0, "unchanged listener stats skip UPDATE")
|
||||
assert.equal(idle2.stats.walCheckpoint, 0)
|
||||
|
||||
bumpPacketMeta("203.0.113.9")
|
||||
const changed = countSqliteWrites(() => {
|
||||
flushPending()
|
||||
})
|
||||
assert.equal(changed.stats.update, 1, "changed packets persist once")
|
||||
assert.equal(changed.stats.walCheckpoint, 0)
|
||||
|
||||
setEngineError("boom")
|
||||
const errWrite = countSqliteWrites(() => {
|
||||
flushPending()
|
||||
})
|
||||
assert.equal(errWrite.stats.update, 1)
|
||||
setEngineError("")
|
||||
flushPending()
|
||||
|
||||
resetEngineForTests()
|
||||
configureEngine({ topN: 20 })
|
||||
ingestParsedFlowsForServerForTests(9, [{
|
||||
src: "10.1.1.1",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 40000,
|
||||
dstPort: 443,
|
||||
bytes: 100,
|
||||
packets: 1,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
}])
|
||||
const withData = countSqliteWrites(() => {
|
||||
flushPending()
|
||||
})
|
||||
assert.equal(withData.stats.walCheckpoint, 0, "flush with data must not TRUNCATE WAL")
|
||||
assert.ok(withData.stats.insert >= 1, "flow upsert writes")
|
||||
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_buckets WHERE server_id = 9`).run()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_minute_stats WHERE server_id = 9`).run()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_minute_dims WHERE server_id = 9`).run()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 9`).run()
|
||||
|
||||
const plan = sqliteDatabase.prepare(`
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT id FROM flow_buckets WHERE bucket_at >= ? ORDER BY bytes DESC LIMIT 100
|
||||
`).all("2000-01-01T00:00:00.000Z") as Array<{ detail?: string }>
|
||||
const planText = plan.map((p) => String(p.detail ?? "")).join(" | ")
|
||||
assert.ok(planText.length > 0, "EXPLAIN QUERY PLAN returned rows")
|
||||
|
||||
invalidateFlowCatalogCache()
|
||||
seedFlowTopologyForTests(null)
|
||||
const topoA = loadFlowTopology()
|
||||
const topoB = loadFlowTopology()
|
||||
assert.equal(topoA, topoB, "topology cache returns same object")
|
||||
invalidateFlowCatalogCache()
|
||||
const topoC = loadFlowTopology()
|
||||
assert.notEqual(topoA, topoC, "invalidate rebuilds topology")
|
||||
|
||||
assert.equal(shouldAppendSchedulerOkEvent("traffic"), false)
|
||||
assert.equal(shouldAppendSchedulerOkEvent("alert_engine"), false)
|
||||
assert.equal(shouldAppendSchedulerOkEvent("gre_bgp"), false)
|
||||
assert.equal(shouldAppendSchedulerOkEvent("backups"), true)
|
||||
assert.equal(shouldAppendSchedulerOkEvent("certificates_renew"), true)
|
||||
assert.equal(shouldAppendSchedulerOkEvent("internet_path"), true)
|
||||
|
||||
savePrevLiveMap("gre", { a: "up" })
|
||||
const prevSame = countSqliteWrites(() => {
|
||||
savePrevLiveMap("gre", { a: "up" })
|
||||
})
|
||||
assert.equal(prevSame.stats.update, 0)
|
||||
assert.equal(prevSame.stats.insert, 0)
|
||||
savePrevLiveMap("gre", { a: "down" })
|
||||
assert.equal(loadPrevLiveMap("gre").a, "down")
|
||||
savePrevLiveMap("gre", { a: "up" })
|
||||
|
||||
const greBefore = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_gre_tunnel_samples`).get() as { n: number }
|
||||
const bgpBefore = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_bgp_peer_samples`).get() as { n: number }
|
||||
await collectGreBgpSnapshotOnce()
|
||||
const greAfter = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_gre_tunnel_samples`).get() as { n: number }
|
||||
const bgpAfter = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_bgp_peer_samples`).get() as { n: number }
|
||||
assert.equal(greAfter.n, greBefore.n)
|
||||
assert.equal(bgpAfter.n, bgpBefore.n)
|
||||
|
||||
const oldId = `evt-old-${randomUUID()}`
|
||||
db.insert(events).values({
|
||||
id: oldId,
|
||||
createdAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
level: "info",
|
||||
eventType: "test.retention",
|
||||
sourceModule: "system",
|
||||
title: "old",
|
||||
message: "old",
|
||||
}).run()
|
||||
const eventsBefore = sqliteDatabase.prepare(
|
||||
`SELECT COUNT(*) AS n FROM events WHERE event_type = 'scheduler.job.ok' AND entity_id = 'alert_engine'`,
|
||||
).get() as { n: number }
|
||||
await executeSchedulerJob("alert_engine")
|
||||
const oldGone = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM events WHERE id = ?`).get(oldId) as { n: number }
|
||||
assert.equal(oldGone.n, 0, "events older than 30 days are purged")
|
||||
const eventsAfter = sqliteDatabase.prepare(
|
||||
`SELECT COUNT(*) AS n FROM events WHERE event_type = 'scheduler.job.ok' AND entity_id = 'alert_engine'`,
|
||||
).get() as { n: number }
|
||||
assert.equal(eventsAfter.n, eventsBefore.n, "quiet jobs do not append scheduler.job.ok")
|
||||
|
||||
resetEngineForTests()
|
||||
console.log("sqlite-write-opt.test.ts: ok")
|
||||
console.log("EXPLAIN listStoredFlowRows:", planText)
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db, sqliteDatabase } from "../db/index.js"
|
||||
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
|
||||
import { appUsers, userInterfaceBindings } from "../db/schema.js"
|
||||
import type {
|
||||
FlowAnalyticsDto,
|
||||
FlowBreakdownRow,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
listFlowRowsForWindow,
|
||||
type PendingFlowRow,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
import { MAX_PENDING, RING_OVERLAY } from "./traffic-flow-engine.js"
|
||||
import { flowDataEpoch, MAX_PENDING, RING_OVERLAY } from "./traffic-flow-engine.js"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||
@@ -33,6 +33,7 @@ import { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-plan
|
||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
import {
|
||||
enGreIfaceNames,
|
||||
getServerCatalog,
|
||||
latestWireBps,
|
||||
loadFlowTopology,
|
||||
resolveClient,
|
||||
@@ -142,14 +143,46 @@ function topLabel(map: Map<string, { bytes: number; packets: number; label?: str
|
||||
}
|
||||
|
||||
export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
||||
const key = analyticsQueryKey(q)
|
||||
const now = Date.now()
|
||||
if (analyticsCache && analyticsCache.key === key && now - analyticsCache.at < ANALYTICS_CACHE_TTL_MS) {
|
||||
return analyticsCache.dto
|
||||
}
|
||||
const dto = buildFlowAnalyticsUncached(q)
|
||||
analyticsCache = { key, at: now, dto }
|
||||
return dto
|
||||
}
|
||||
|
||||
export function resetFlowAnalyticsCacheForTests(): void {
|
||||
analyticsCache = null
|
||||
}
|
||||
|
||||
function analyticsQueryKey(q: FlowAnalyticsQuery): string {
|
||||
return JSON.stringify({
|
||||
epoch: flowDataEpoch(),
|
||||
minutes: q.minutes,
|
||||
serverId: q.serverId ?? null,
|
||||
userId: q.userId ?? null,
|
||||
iface: q.iface ?? null,
|
||||
dedup: q.dedup !== false,
|
||||
excludeMesh: q.excludeMesh !== false,
|
||||
excludeOverlay: q.excludeOverlay !== false,
|
||||
skipHeavy: Boolean(q.skipHeavy),
|
||||
})
|
||||
}
|
||||
|
||||
const ANALYTICS_CACHE_TTL_MS = 2000
|
||||
let analyticsCache: { key: string; at: number; dto: FlowAnalyticsDto } | null = null
|
||||
|
||||
function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const top = Math.min(50, Math.max(10, settings.topN))
|
||||
const windowSec = Math.max(60, q.minutes * 60)
|
||||
const raw = listFlowRowsForWindow(q.minutes)
|
||||
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||
const countryById = new Map(serverRows.map((s) => [s.id, (s.country || "").toUpperCase() || "UN"]))
|
||||
const catalog = getServerCatalog()
|
||||
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
|
||||
const countryById = new Map([...catalog.byId].map(([id, s]) => [id, s.country]))
|
||||
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||
const excludeMesh = q.excludeMesh !== false
|
||||
@@ -478,19 +511,19 @@ export function listFlowExporters(minutes: number): FlowExportersDto {
|
||||
const { bytes, sessions } = summarizeByServer(rows)
|
||||
const ids = new Set<number>([...bytes.keys()])
|
||||
for (const p of listHostPeers()) ids.add(p.serverId)
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const catalog = getServerCatalog()
|
||||
const emptySeries = Array(60).fill(0) as number[]
|
||||
const exporters = serverRows
|
||||
const exporters = catalog.list
|
||||
.filter((s) => ids.has(s.id))
|
||||
.map((s) => {
|
||||
const ring = getRingMbps(s.id, "__all__")
|
||||
const total = bytes.get(s.id) ?? 0
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
name: s.name,
|
||||
subtitle: s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: snapshotStatus(s.id),
|
||||
rxNow: ring.rxNow || (total * 8) / Math.max(60, minutes * 60) / 1_000_000,
|
||||
txNow: ring.txNow,
|
||||
|
||||
@@ -4,7 +4,8 @@ import { classifyFlowPlaneLite } from "./traffic-flow-planes.js"
|
||||
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
import { classifyFlowDst } from "./traffic-flow-classify.js"
|
||||
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
||||
import { enqueueRipeMisses, lookupRipeCached, pruneRipeSqlite } from "./traffic-flow-ripe.js"
|
||||
import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
|
||||
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
@@ -94,10 +95,27 @@ let dropped = 0
|
||||
let rowsStored = 0
|
||||
let lastFlushUsedTransaction = false
|
||||
let lastPruneAt = 0
|
||||
let lastPassiveCheckpointAt = Date.now()
|
||||
let dataEpoch = 0
|
||||
let lastPersistedStats: {
|
||||
packetsReceived: number
|
||||
lastDatagramAt: string | null
|
||||
lastExporterIp: string | null
|
||||
lastError: string
|
||||
} | null = null
|
||||
let exporterCtx: ExporterResolveCtx | null = null
|
||||
|
||||
const PRUNE_MS = 5 * 60_000
|
||||
const LIVE_WINDOW_MS = 15 * 60_000
|
||||
const PASSIVE_CHECKPOINT_MS = 60_000
|
||||
|
||||
function bumpDataEpoch(): void {
|
||||
dataEpoch += 1
|
||||
}
|
||||
|
||||
export function flowDataEpoch(): number {
|
||||
return dataEpoch
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
@@ -234,6 +252,7 @@ export function getEngineStats(): EngineStats {
|
||||
}
|
||||
|
||||
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
|
||||
if (flows.length) bumpDataEpoch()
|
||||
const bucketAt = minuteBucketIso()
|
||||
const ripeMisses: string[] = []
|
||||
for (const raw of flows) {
|
||||
@@ -422,7 +441,16 @@ export function applyRingSnapshot(rows: Array<{ key: string; inBps: number[]; ou
|
||||
}
|
||||
}
|
||||
|
||||
function persistListenerStats(handle: SqliteHandle): void {
|
||||
function persistListenerStats(handle: SqliteHandle): boolean {
|
||||
if (
|
||||
lastPersistedStats
|
||||
&& lastPersistedStats.packetsReceived === packetsReceived
|
||||
&& lastPersistedStats.lastDatagramAt === lastDatagramAt
|
||||
&& lastPersistedStats.lastExporterIp === lastExporterIp
|
||||
&& lastPersistedStats.lastError === lastError
|
||||
) {
|
||||
return false
|
||||
}
|
||||
handle.prepare(`
|
||||
UPDATE traffic_flow_settings
|
||||
SET packets_received = @packetsReceived,
|
||||
@@ -438,6 +466,25 @@ function persistListenerStats(handle: SqliteHandle): void {
|
||||
lastError,
|
||||
updatedAt: nowIso(),
|
||||
})
|
||||
lastPersistedStats = {
|
||||
packetsReceived,
|
||||
lastDatagramAt,
|
||||
lastExporterIp,
|
||||
lastError,
|
||||
}
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
return true
|
||||
}
|
||||
|
||||
function maybePassiveCheckpoint(handle: SqliteHandle): void {
|
||||
const now = Date.now()
|
||||
if (now - lastPassiveCheckpointAt < PASSIVE_CHECKPOINT_MS) return
|
||||
lastPassiveCheckpointAt = now
|
||||
try {
|
||||
handle.pragma("wal_checkpoint(PASSIVE)")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function upsertMinuteAndDaily(handle: SqliteHandle): void {
|
||||
@@ -586,6 +633,7 @@ function pruneStored(handle: SqliteHandle): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
pruneRipeSqlite(now)
|
||||
}
|
||||
|
||||
function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
|
||||
@@ -616,6 +664,7 @@ export function flushPending(): void {
|
||||
persistListenerStats(handle)
|
||||
if (pending.size === 0 && minuteRollup.size === 0 && minuteDims.size === 0) {
|
||||
pruneStored(handle)
|
||||
maybePassiveCheckpoint(handle)
|
||||
lastFlushUsedTransaction = false
|
||||
return
|
||||
}
|
||||
@@ -665,6 +714,7 @@ export function flushPending(): void {
|
||||
tx(rows)
|
||||
lastFlushUsedTransaction = true
|
||||
rowsStored += rows.length
|
||||
bumpDataEpoch()
|
||||
} catch {
|
||||
for (const r of rows) {
|
||||
try {
|
||||
@@ -697,11 +747,7 @@ export function flushPending(): void {
|
||||
/* rollup best-effort */
|
||||
}
|
||||
pruneStored(handle)
|
||||
try {
|
||||
handle.pragma("wal_checkpoint(TRUNCATE)")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
maybePassiveCheckpoint(handle)
|
||||
}
|
||||
|
||||
export function lastFlushUsedTransactionForTests(): boolean {
|
||||
@@ -736,6 +782,9 @@ export function resetEngineForTests(): void {
|
||||
rowsStored = 0
|
||||
lastFlushUsedTransaction = false
|
||||
lastPruneAt = 0
|
||||
lastPassiveCheckpointAt = Date.now()
|
||||
lastPersistedStats = null
|
||||
bumpDataEpoch()
|
||||
pendingCap = MAX_PENDING
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { SQLITE_BUSY_TIMEOUT_MS, sqliteDatabase } from "../db/index.js"
|
||||
import {
|
||||
SQLITE_BUSY_TIMEOUT_MS,
|
||||
SQLITE_CACHE_SIZE_KIB,
|
||||
SQLITE_WAL_AUTOCHECKPOINT_PAGES,
|
||||
sqliteDatabase,
|
||||
} from "../db/index.js"
|
||||
import {
|
||||
MAX_FLOW_LIVE_SUBSCRIBERS,
|
||||
resetFlowLiveSlotsForTests,
|
||||
@@ -11,6 +16,16 @@ const busy = sqliteDatabase.pragma("busy_timeout") as Array<{ busy_timeout: numb
|
||||
const busyValue = Array.isArray(busy) ? Number(Object.values(busy[0] ?? {})[0]) : Number(busy)
|
||||
assert.equal(busyValue, SQLITE_BUSY_TIMEOUT_MS)
|
||||
|
||||
function pragmaNum(name: string): number {
|
||||
const rows = sqliteDatabase.pragma(name) as Array<Record<string, number>>
|
||||
const row = Array.isArray(rows) ? rows[0] : rows
|
||||
return Number(Object.values(row ?? {})[0])
|
||||
}
|
||||
|
||||
assert.equal(pragmaNum("wal_autocheckpoint"), SQLITE_WAL_AUTOCHECKPOINT_PAGES)
|
||||
assert.equal(pragmaNum("cache_size"), -SQLITE_CACHE_SIZE_KIB)
|
||||
assert.equal(pragmaNum("temp_store"), 2)
|
||||
|
||||
resetFlowLiveSlotsForTests()
|
||||
for (let i = 0; i < MAX_FLOW_LIVE_SUBSCRIBERS; i++) {
|
||||
assert.equal(tryAcquireFlowLiveSlot(), true)
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./traffic-flow-settings.js"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
import { getServerCatalog } from "./traffic-flow-topology.js"
|
||||
|
||||
export type { PendingFlowRow }
|
||||
|
||||
@@ -332,8 +333,8 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const runtime = getFlowRuntimeCounters()
|
||||
const rows = listFlowRowsForWindow(minutes)
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||
const catalog = getServerCatalog()
|
||||
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
|
||||
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
||||
const protoBytes = new Map<number, number>()
|
||||
const srcs = new Set<string>()
|
||||
|
||||
@@ -371,4 +371,47 @@ try {
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
seedFlowTopologyForTests(topo)
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*1", name: "SWE-VEESP" },
|
||||
])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "173.194.151.65",
|
||||
dst: "10.200.100.53",
|
||||
proto: 6,
|
||||
srcPort: 443,
|
||||
dstPort: 57182,
|
||||
bytes: 9_000,
|
||||
packets: 80,
|
||||
inIface: "1",
|
||||
outIface: "1",
|
||||
nextHop: "",
|
||||
},
|
||||
])
|
||||
try {
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const wanOnly = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||
const googleEdge = wanOnly.serviceEdges?.find((e) => e.toId === "svc:google")
|
||||
assert.ok(googleEdge, "Google WAN без GRE payload")
|
||||
assert.equal(googleEdge.fromId, "9", "единственный EN, даже без nextHop")
|
||||
assert.ok(googleEdge.bps > 0, "скорость на hop EN→сервис")
|
||||
assert.ok(!(wanOnly.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH")
|
||||
const googlePath = wanOnly.servicePaths?.find((p) => p.serviceId === "svc:google")
|
||||
assert.ok(googlePath, "путь WAN Google")
|
||||
assert.equal(googlePath.viaId, "7", "via = JH exporter")
|
||||
assert.equal(googlePath.enId, "9", "якорь EN")
|
||||
assert.ok(googlePath.bps > 0, "скорость на пути клиента")
|
||||
} finally {
|
||||
seedFlowTopologyForTests(null)
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
console.log("traffic-flow-map-hops.test.ts: ok")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
|
||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, userInterfaceBindings } from "../db/schema.js"
|
||||
import { userInterfaceBindings } from "../db/schema.js"
|
||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||
import {
|
||||
isNamedInternetService,
|
||||
@@ -15,7 +15,8 @@ import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
|
||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
|
||||
import { loadFlowTopology, resolveClient, resolveEn } from "./traffic-flow-topology.js"
|
||||
import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog } from "./traffic-flow-topology.js"
|
||||
import { flowDataEpoch } from "./traffic-flow-engine.js"
|
||||
|
||||
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
||||
export const MAP_SERVICE_NODE_CAP = 20
|
||||
@@ -45,9 +46,14 @@ interface HopAcc {
|
||||
bytesRev: number
|
||||
}
|
||||
|
||||
interface ClientAcc {
|
||||
name: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
interface FromAcc {
|
||||
bytes: number
|
||||
clients: Map<string, string>
|
||||
clients: Map<string, ClientAcc>
|
||||
}
|
||||
|
||||
interface DstAcc {
|
||||
@@ -58,15 +64,26 @@ interface DstAcc {
|
||||
fromBytes: Map<string, FromAcc>
|
||||
}
|
||||
|
||||
function bumpClient(clients: Map<string, ClientAcc>, bytes: number, client: { userId: string; name: string } | null): void {
|
||||
const id = client?.userId || "—"
|
||||
const name = client?.name || "—"
|
||||
const prev = clients.get(id)
|
||||
if (prev) {
|
||||
prev.bytes += bytes
|
||||
return
|
||||
}
|
||||
clients.set(id, { name, bytes })
|
||||
}
|
||||
|
||||
function bumpFrom(acc: DstAcc, exporterId: string, bytes: number, client: { userId: string; name: string } | null): void {
|
||||
const prev = acc.fromBytes.get(exporterId)
|
||||
if (prev) {
|
||||
prev.bytes += bytes
|
||||
if (client) prev.clients.set(client.userId, client.name)
|
||||
bumpClient(prev.clients, bytes, client)
|
||||
return
|
||||
}
|
||||
const clients = new Map<string, string>()
|
||||
if (client) clients.set(client.userId, client.name)
|
||||
const clients = new Map<string, ClientAcc>()
|
||||
bumpClient(clients, bytes, client)
|
||||
acc.fromBytes.set(exporterId, { bytes, clients })
|
||||
}
|
||||
|
||||
@@ -84,6 +101,7 @@ export function clampMapServiceMinSharePct(n: unknown): number {
|
||||
|
||||
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
|
||||
return JSON.stringify({
|
||||
epoch: flowDataEpoch(),
|
||||
minutes: q.minutes,
|
||||
serverId: q.serverId ?? null,
|
||||
userId: q.userId ?? null,
|
||||
@@ -178,8 +196,8 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
const windowSec = Math.max(60, q.minutes * 60)
|
||||
const raw = listFlowRowsForWindow(q.minutes)
|
||||
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||
const catalog = getServerCatalog()
|
||||
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
|
||||
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||
const excludeMesh = q.excludeMesh !== false
|
||||
@@ -326,15 +344,47 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
bytesRev: number
|
||||
clients: Map<string, string>
|
||||
}>()
|
||||
const svcPaths = new Map<string, {
|
||||
clientId: string
|
||||
clientName: string
|
||||
viaId: string
|
||||
viaName: string
|
||||
enId: string
|
||||
enName: string
|
||||
serviceId: string
|
||||
bytes: number
|
||||
}>()
|
||||
|
||||
for (const h of hops.values()) {
|
||||
if (h.kind !== "gre" || !h.toId) continue
|
||||
const from = Number(h.fromId)
|
||||
const to = Number(h.toId)
|
||||
if (!Number.isFinite(from) || !Number.isFinite(to)) continue
|
||||
if (enIds.has(to) && !enIds.has(from)) jhToEn.set(from, to)
|
||||
}
|
||||
|
||||
const soleEnId = topo.enNodes.length === 1 ? String(topo.enNodes[0]!.id) : null
|
||||
|
||||
function anchorEnId(exporterId: string): string | null {
|
||||
const n = Number(exporterId)
|
||||
if (enIds.has(n)) return exporterId
|
||||
const mapped = jhToEn.get(n)
|
||||
if (mapped != null) return String(mapped)
|
||||
if (soleEnId) return soleEnId
|
||||
return null
|
||||
}
|
||||
|
||||
function nodeName(id: string): string {
|
||||
const n = Number(id)
|
||||
if (Number.isFinite(n)) {
|
||||
const fromDb = nameById.get(n)
|
||||
if (fromDb) return fromDb
|
||||
}
|
||||
const en = topo.enNodes.find((node) => String(node.id) === id)
|
||||
if (en?.name) return en.name
|
||||
return id
|
||||
}
|
||||
|
||||
for (const [dst, acc] of dstAcc) {
|
||||
const ripe = lookupRipeCached(dst)
|
||||
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
|
||||
@@ -348,10 +398,14 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
if (!fromId) continue
|
||||
const edgeKey = `${fromId}|${toId}`
|
||||
const prevEdge = svcEdges.get(edgeKey)
|
||||
const namedClients = new Map<string, string>()
|
||||
for (const [id, c] of from.clients) {
|
||||
if (id !== "—") namedClients.set(id, c.name)
|
||||
}
|
||||
if (prevEdge) {
|
||||
prevEdge.bytes += from.bytes
|
||||
prevEdge.bytesFwd += from.bytes
|
||||
for (const [id, name] of from.clients) prevEdge.clients.set(id, name)
|
||||
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
|
||||
} else {
|
||||
svcEdges.set(edgeKey, {
|
||||
fromId,
|
||||
@@ -359,9 +413,29 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
bytes: from.bytes,
|
||||
bytesFwd: from.bytes,
|
||||
bytesRev: 0,
|
||||
clients: new Map(from.clients),
|
||||
clients: namedClients,
|
||||
})
|
||||
}
|
||||
const enName = nodeName(fromId)
|
||||
const viaName = nodeName(exporterId)
|
||||
for (const [clientId, c] of from.clients) {
|
||||
const pathKey = `${clientId}|${exporterId}|${fromId}|${toId}`
|
||||
const prevPath = svcPaths.get(pathKey)
|
||||
if (prevPath) {
|
||||
prevPath.bytes += c.bytes
|
||||
} else {
|
||||
svcPaths.set(pathKey, {
|
||||
clientId,
|
||||
clientName: c.name,
|
||||
viaId: exporterId,
|
||||
viaName,
|
||||
enId: fromId,
|
||||
enName,
|
||||
serviceId: toId,
|
||||
bytes: c.bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +473,21 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
})
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
|
||||
const servicePaths: FlowMapServicePath[] = [...svcPaths.values()]
|
||||
.filter((p) => keepSvc.has(p.serviceId))
|
||||
.map((p) => ({
|
||||
clientId: p.clientId,
|
||||
clientName: p.clientName,
|
||||
viaId: p.viaId,
|
||||
viaName: p.viaName,
|
||||
enId: p.enId,
|
||||
enName: p.enName,
|
||||
serviceId: p.serviceId,
|
||||
bytes: p.bytes,
|
||||
bps: (p.bytes * 8) / windowSec,
|
||||
}))
|
||||
.sort((a, b) => b.bps - a.bps)
|
||||
|
||||
const listener = getFlowListenerState()
|
||||
return {
|
||||
hops: [...hops.values()]
|
||||
@@ -410,6 +499,7 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
||||
totalBytes,
|
||||
services,
|
||||
serviceEdges,
|
||||
servicePaths,
|
||||
mapServiceMinSharePct: minSharePct,
|
||||
dedupApplied: wantDedup,
|
||||
excludeMeshApplied: excludeMesh,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./traffic-flow-settings.js"
|
||||
import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
|
||||
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
||||
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
|
||||
|
||||
const IFACE_NAME = "wg-flow"
|
||||
const JH_LISTEN_PORT = 13232
|
||||
@@ -288,6 +289,7 @@ export async function applyFlowOverlay(
|
||||
mgmtTunnelIp: address,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(servers.id, server.id)).run()
|
||||
invalidateFlowCatalogCache()
|
||||
|
||||
upsertHostPeer({
|
||||
serverId: server.id,
|
||||
|
||||
@@ -43,8 +43,8 @@ try {
|
||||
`).run(serverId)
|
||||
sqliteDatabase.prepare(`
|
||||
INSERT INTO flow_ip_meta (prefix, asn, country, holder, ok, fetched_at)
|
||||
VALUES ('8.8.8.0/24', 15169, 'US', 'Google', 1, '2026-01-01T00:00:00.000Z')
|
||||
`).run()
|
||||
VALUES ('8.8.8.0/24', 15169, 'US', 'Google', 1, ?)
|
||||
`).run(new Date().toISOString())
|
||||
sqliteDatabase.prepare(`
|
||||
UPDATE traffic_flow_settings SET packets_received = 42, last_exporter_ip = '10.255.254.3' WHERE id = 1
|
||||
`).run()
|
||||
|
||||
@@ -239,6 +239,20 @@ function persistAsn(asn: number, holder: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Удаляет просроченный RIPE-кэш с диска (hit 24h / negative 6h). */
|
||||
export function pruneRipeSqlite(nowMs = Date.now()): void {
|
||||
if (!persistEnabled) return
|
||||
try {
|
||||
const hitCutoff = new Date(nowMs - HIT_TTL_MS).toISOString()
|
||||
const negCutoff = new Date(nowMs - NEG_TTL_MS).toISOString()
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_ip_meta WHERE ok != 0 AND fetched_at < ?`).run(hitCutoff)
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_ip_meta WHERE ok = 0 AND fetched_at < ?`).run(negCutoff)
|
||||
sqliteDatabase.prepare(`DELETE FROM flow_asn_meta WHERE fetched_at < ?`).run(hitCutoff)
|
||||
} catch {
|
||||
/* table may not exist in isolated tests */
|
||||
}
|
||||
}
|
||||
|
||||
function negative(prefix: string): FlowIpMeta {
|
||||
return {
|
||||
prefix,
|
||||
|
||||
@@ -4,10 +4,42 @@ import { trafficFlowSettings } from "../db/schema.js"
|
||||
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
|
||||
import { generateWireGuardKeyPair } from "./wg-keys.js"
|
||||
|
||||
let settingsRowCache: ReturnType<typeof readSettingsRow> | null = null
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
export function invalidateTrafficFlowSettingsCache(): void {
|
||||
settingsRowCache = null
|
||||
}
|
||||
|
||||
function readSettingsRow() {
|
||||
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
export function getTrafficFlowSettingsRow() {
|
||||
if (settingsRowCache) return settingsRowCache
|
||||
const row = readSettingsRow()
|
||||
if (row) {
|
||||
settingsRowCache = row
|
||||
return row
|
||||
}
|
||||
const now = nowIso()
|
||||
db.insert(trafficFlowSettings).values({
|
||||
id: 1,
|
||||
enabled: false,
|
||||
collectorIp: "10.255.254.1",
|
||||
flowListenPort: 4739,
|
||||
wgListenPort: 51821,
|
||||
prefix: "10.255.254.0/24",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
settingsRowCache = readSettingsRow()
|
||||
return settingsRowCache!
|
||||
}
|
||||
|
||||
function parsePeers(raw: string): FlowHostPeer[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
@@ -20,23 +52,6 @@ function parsePeers(raw: string): FlowHostPeer[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrafficFlowSettingsRow() {
|
||||
const row = db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||
if (row) return row
|
||||
const now = nowIso()
|
||||
db.insert(trafficFlowSettings).values({
|
||||
id: 1,
|
||||
enabled: false,
|
||||
collectorIp: "10.255.254.1",
|
||||
flowListenPort: 4739,
|
||||
wgListenPort: 51821,
|
||||
prefix: "10.255.254.0/24",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
export function toTrafficFlowSettingsDto(
|
||||
listener: { bound: boolean; address: string | null },
|
||||
): TrafficFlowSettingsDto {
|
||||
@@ -81,6 +96,7 @@ export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
|
||||
: Math.min(100, Math.max(0, patch.mapServiceMinSharePct)),
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
return getTrafficFlowSettingsRow()
|
||||
}
|
||||
|
||||
@@ -95,6 +111,7 @@ export function ensureHostKeys(): { publicKey: string; created: boolean } {
|
||||
hostPrivateKey: keys.privateKey,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
return { publicKey: keys.publicKey, created: true }
|
||||
}
|
||||
|
||||
@@ -106,6 +123,7 @@ export function upsertHostPeer(peer: FlowHostPeer) {
|
||||
peersJson: JSON.stringify(peers),
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
}
|
||||
|
||||
export function recordFlowPacket(exporterIp: string) {
|
||||
@@ -116,6 +134,7 @@ export function recordFlowPacket(exporterIp: string) {
|
||||
packetsReceived: row.packetsReceived + 1,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
}
|
||||
|
||||
export function recordFlowListenerError(message: string) {
|
||||
@@ -123,6 +142,7 @@ export function recordFlowListenerError(message: string) {
|
||||
lastError: message,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
}
|
||||
|
||||
export function enableTrafficFlowIngest() {
|
||||
@@ -130,6 +150,7 @@ export function enableTrafficFlowIngest() {
|
||||
enabled: true,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
}
|
||||
|
||||
export function listHostPeers(): FlowHostPeer[] {
|
||||
@@ -144,4 +165,5 @@ export function resetFlowIngestCounters(): void {
|
||||
lastError: "",
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
}
|
||||
|
||||
@@ -27,7 +27,44 @@ export interface FlowTopology {
|
||||
plane: PlaneTopology
|
||||
}
|
||||
|
||||
export interface ServerCatalogEntry {
|
||||
id: number
|
||||
name: string
|
||||
country: string
|
||||
host: string
|
||||
type: string
|
||||
site: string
|
||||
}
|
||||
|
||||
const CATALOG_TTL_MS = 5_000
|
||||
|
||||
let seeded: FlowTopology | null = null
|
||||
let topologyCache: { at: number; topo: FlowTopology } | null = null
|
||||
let serverCatalogCache: { at: number; list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> } | null = null
|
||||
|
||||
export function invalidateFlowCatalogCache(): void {
|
||||
topologyCache = null
|
||||
serverCatalogCache = null
|
||||
}
|
||||
|
||||
export function getServerCatalog(): { list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> } {
|
||||
const now = Date.now()
|
||||
if (serverCatalogCache && now - serverCatalogCache.at < CATALOG_TTL_MS) {
|
||||
return serverCatalogCache
|
||||
}
|
||||
const rows = db.select().from(servers).all()
|
||||
const list: ServerCatalogEntry[] = rows.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name || s.host,
|
||||
country: (s.country || "").toUpperCase() || "UN",
|
||||
host: s.host,
|
||||
type: s.type,
|
||||
site: s.site || "—",
|
||||
}))
|
||||
const byId = new Map(list.map((s) => [s.id, s]))
|
||||
serverCatalogCache = { at: now, list, byId }
|
||||
return serverCatalogCache
|
||||
}
|
||||
|
||||
function parseWanUplinks(raw: string): Array<{ iface?: string; ip?: string }> {
|
||||
try {
|
||||
@@ -44,6 +81,8 @@ function ifaceKey(serverId: number, name: string): string {
|
||||
|
||||
export function loadFlowTopology(): FlowTopology {
|
||||
if (seeded) return seeded
|
||||
const now = Date.now()
|
||||
if (topologyCache && now - topologyCache.at < CATALOG_TTL_MS) return topologyCache.topo
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const users = db.select().from(appUsers).all()
|
||||
const binds = db.select().from(userInterfaceBindings).all()
|
||||
@@ -82,7 +121,7 @@ export function loadFlowTopology(): FlowTopology {
|
||||
for (const h of hosts) jhHosts.add(h)
|
||||
}
|
||||
}
|
||||
return {
|
||||
const topo: FlowTopology = {
|
||||
clientIfaces,
|
||||
clientByIface,
|
||||
enNodes,
|
||||
@@ -95,10 +134,13 @@ export function loadFlowTopology(): FlowTopology {
|
||||
jhHosts,
|
||||
},
|
||||
}
|
||||
topologyCache = { at: Date.now(), topo }
|
||||
return topo
|
||||
}
|
||||
|
||||
export function seedFlowTopologyForTests(topo: FlowTopology | null): void {
|
||||
seeded = topo
|
||||
invalidateFlowCatalogCache()
|
||||
}
|
||||
|
||||
export function resolveClient(
|
||||
|
||||
@@ -40,6 +40,10 @@ const H = 580
|
||||
const MARGIN = 72
|
||||
const SERVICE_COL_W = 150
|
||||
|
||||
/** Карточка конечного сервиса на карте (центр = позиция узла). */
|
||||
export const MAP_SERVICE_NODE_W = 86
|
||||
export const MAP_SERVICE_NODE_H = 58
|
||||
|
||||
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
|
||||
export const NETWORK_MAP_PIPELINE_Y = 300
|
||||
|
||||
@@ -828,3 +832,42 @@ export function buildGreMapEdges(
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрезать отрезок центр круга → центр прямоугольника по ободу круга и AABB карточки.
|
||||
* Пунктир EN→сервис визуально упирается в край, как GRE под кругами узлов.
|
||||
*/
|
||||
export function clipSegmentCircleToRect(
|
||||
x1: number,
|
||||
y1: number,
|
||||
r: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
hw: number,
|
||||
hh: number,
|
||||
pad = 1.5,
|
||||
): { x1: number; y1: number; x2: number; y2: number } {
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
const len = Math.hypot(dx, dy)
|
||||
if (len < 1e-6) return { x1, y1, x2, y2 }
|
||||
const ux = dx / len
|
||||
const uy = dy / len
|
||||
const sx = x1 + ux * (r + pad)
|
||||
const sy = y1 + uy * (r + pad)
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
const u = Math.min(
|
||||
absDx < 1e-9 ? 1 : (hw + pad) / absDx,
|
||||
absDy < 1e-9 ? 1 : (hh + pad) / absDy,
|
||||
)
|
||||
const uu = Math.min(Math.max(u, 0), 0.48)
|
||||
const ex = x2 - dx * uu
|
||||
const ey = y2 - dy * uu
|
||||
if ((ex - sx) * dx + (ey - sy) * dy <= 0) {
|
||||
const mx = (x1 + x2) / 2
|
||||
const my = (y1 + y2) / 2
|
||||
return { x1: mx - ux * 2, y1: my - uy * 2, x2: mx + ux * 2, y2: my + uy * 2 }
|
||||
}
|
||||
return { x1: sx, y1: sy, x2: ex, y2: ey }
|
||||
}
|
||||
|
||||
@@ -289,6 +289,18 @@ export const flowMapServiceEdgeDtoSchema = z.object({
|
||||
})).optional(),
|
||||
})
|
||||
|
||||
export const flowMapServicePathDtoSchema = z.object({
|
||||
clientId: z.string(),
|
||||
clientName: z.string(),
|
||||
viaId: z.string(),
|
||||
viaName: z.string(),
|
||||
enId: z.string(),
|
||||
enName: z.string(),
|
||||
serviceId: z.string(),
|
||||
bytes: z.number().nonnegative(),
|
||||
bps: z.number().nonnegative(),
|
||||
})
|
||||
|
||||
export const flowMapHopsDtoSchema = z.object({
|
||||
hops: z.array(flowMapHopDtoSchema),
|
||||
live: z.boolean(),
|
||||
@@ -297,6 +309,7 @@ export const flowMapHopsDtoSchema = z.object({
|
||||
totalBytes: z.number().nonnegative().optional(),
|
||||
services: z.array(flowMapServiceDtoSchema).optional(),
|
||||
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
|
||||
servicePaths: z.array(flowMapServicePathDtoSchema).optional(),
|
||||
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
|
||||
dedupApplied: z.boolean(),
|
||||
excludeMeshApplied: z.boolean(),
|
||||
@@ -319,4 +332,5 @@ export type FlowMapHopKind = z.infer<typeof flowMapHopKindSchema>
|
||||
export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
|
||||
export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
|
||||
export type FlowMapServiceEdge = z.infer<typeof flowMapServiceEdgeDtoSchema>
|
||||
export type FlowMapServicePath = z.infer<typeof flowMapServicePathDtoSchema>
|
||||
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
|
||||
|
||||
Reference in New Issue
Block a user