Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6332d83a12 | ||
|
|
3834c40aa8 | ||
|
|
90c8c393e5 |
@@ -41,6 +41,15 @@ import {
|
|||||||
wanJhEdgeMapKey,
|
wanJhEdgeMapKey,
|
||||||
type GreSpeedProbeSnapshot,
|
type GreSpeedProbeSnapshot,
|
||||||
} from "@/lib/map-gre-speed-probe"
|
} from "@/lib/map-gre-speed-probe"
|
||||||
|
import {
|
||||||
|
formatNetflowDir,
|
||||||
|
formatNetflowRate,
|
||||||
|
hopHasRate,
|
||||||
|
matchNetflowForGreEdge,
|
||||||
|
matchNetflowForWan,
|
||||||
|
type MatchedNetflowHop,
|
||||||
|
} from "@/lib/map-netflow-hops"
|
||||||
|
import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
@@ -420,6 +429,53 @@ function GreEdgeMetricBadge({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Живой поток NetFlow (не ёмкость канала / не BT). */
|
||||||
|
function NetflowRateBadge({
|
||||||
|
mx,
|
||||||
|
my,
|
||||||
|
hop,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
mx: number
|
||||||
|
my: number
|
||||||
|
hop: MatchedNetflowHop
|
||||||
|
onOpen?: (e: React.MouseEvent<SVGElement>) => void
|
||||||
|
}) {
|
||||||
|
const showDir = hop.bpsFwd > 0 && hop.bpsRev > 0
|
||||||
|
const bw = showDir ? 86 : 72
|
||||||
|
const bh = showDir ? 32 : 20
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
transform={`translate(${mx},${my})`}
|
||||||
|
style={{ cursor: onOpen ? "pointer" : "default" }}
|
||||||
|
onPointerDown={(e) => { e.stopPropagation() }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onOpen?.(e) }}
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
|
||||||
|
</title>
|
||||||
|
<rect
|
||||||
|
x={-bw / 2}
|
||||||
|
y={-bh / 2}
|
||||||
|
width={bw}
|
||||||
|
height={bh}
|
||||||
|
rx="6"
|
||||||
|
fill="rgba(6,13,26,0.94)"
|
||||||
|
stroke="#34d399"
|
||||||
|
strokeWidth="1.15"
|
||||||
|
/>
|
||||||
|
<text textAnchor="middle" y={showDir ? "-4" : "4"} fontFamily="ui-monospace,monospace">
|
||||||
|
<tspan fill="#6ee7b7" fontSize="8" fontWeight="700">{formatNetflowRate(hop)}</tspan>
|
||||||
|
</text>
|
||||||
|
{showDir && (
|
||||||
|
<text textAnchor="middle" y="10" fontFamily="ui-monospace,monospace">
|
||||||
|
<tspan fill="#34d399" fontSize="6.5" fontWeight="600">{formatNetflowDir(hop)}</tspan>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
|
function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
|
||||||
const ss = STATUS_STYLE[n.status]
|
const ss = STATUS_STYLE[n.status]
|
||||||
const ts = TYPE_STYLE[n.type]
|
const ts = TYPE_STYLE[n.type]
|
||||||
@@ -784,6 +840,7 @@ export default function NetworkMapPage() {
|
|||||||
const [mapServers, setMapServers] = useState<Server[]>([])
|
const [mapServers, setMapServers] = useState<Server[]>([])
|
||||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
||||||
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
||||||
|
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
|
||||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||||
const [dataError, setDataError] = useState<string | null>(null)
|
const [dataError, setDataError] = useState<string | null>(null)
|
||||||
@@ -935,11 +992,31 @@ export default function NetworkMapPage() {
|
|||||||
const [filter, setFilter] = useState<FilterKey>("all")
|
const [filter, setFilter] = useState<FilterKey>("all")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [showPingBadges, setShowPingBadges] = useState(true)
|
const [showPingBadges, setShowPingBadges] = useState(true)
|
||||||
|
const [showNetflow, setShowNetflow] = useState(true)
|
||||||
const [showAnimDots, setShowAnimDots] = useState(true)
|
const [showAnimDots, setShowAnimDots] = useState(true)
|
||||||
const [showMinimap, setShowMinimap] = useState(true)
|
const [showMinimap, setShowMinimap] = useState(true)
|
||||||
const [showHints, setShowHints] = useState(false)
|
const [showHints, setShowHints] = useState(false)
|
||||||
const [showLayers, setShowLayers] = useState(false)
|
const [showLayers, setShowLayers] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!useLiveData || !showNetflow) {
|
||||||
|
queueMicrotask(() => setMapHops([]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
const tick = () => {
|
||||||
|
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m")
|
||||||
|
.then((res) => { if (!cancelled) setMapHops(res.hops ?? []) })
|
||||||
|
.catch(() => { if (!cancelled) setMapHops([]) })
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
const id = window.setInterval(tick, 4000)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearInterval(id)
|
||||||
|
}
|
||||||
|
}, [useLiveData, showNetflow, apiFetch])
|
||||||
|
|
||||||
const effectiveSatPos = useMemo(() => {
|
const effectiveSatPos = useMemo(() => {
|
||||||
const out: Record<string, { x: number; y: number }[]> = {}
|
const out: Record<string, { x: number; y: number }[]> = {}
|
||||||
mapServers
|
mapServers
|
||||||
@@ -1100,6 +1177,32 @@ export default function NetworkMapPage() {
|
|||||||
return out
|
return out
|
||||||
}, [greEdges])
|
}, [greEdges])
|
||||||
|
|
||||||
|
const netflowByGreKey = useMemo(() => {
|
||||||
|
const m = new Map<string, MatchedNetflowHop>()
|
||||||
|
if (!showNetflow) return m
|
||||||
|
for (const e of greEdges) {
|
||||||
|
const hop = matchNetflowForGreEdge(e, mapHops)
|
||||||
|
if (hopHasRate(hop)) m.set(greEdgeKey(e), hop)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}, [greEdges, mapHops, showNetflow])
|
||||||
|
|
||||||
|
const netflowByWanKey = useMemo(() => {
|
||||||
|
const m = new Map<string, MatchedNetflowHop>()
|
||||||
|
if (!showNetflow) return m
|
||||||
|
for (const home of homeRouters) {
|
||||||
|
for (const [wIdx, wan] of (home.wanUplinks ?? []).entries()) {
|
||||||
|
const hop = matchNetflowForWan(home.id, wan.iface, mapHops)
|
||||||
|
if (!hopHasRate(hop)) continue
|
||||||
|
m.set(`${home.id}\t${wIdx}`, hop)
|
||||||
|
for (const e of wanJhEdges) {
|
||||||
|
if (e.homeId === home.id && e.wanIdx === wIdx) m.set(wanJhEdgeMapKey(e), hop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
|
||||||
|
|
||||||
const nodes = mapServers
|
const nodes = mapServers
|
||||||
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
|
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
|
||||||
// Визуальный приоритет: HR поверх JH, JH поверх EN.
|
// Визуальный приоритет: HR поверх JH, JH поверх EN.
|
||||||
@@ -1461,6 +1564,7 @@ export default function NetworkMapPage() {
|
|||||||
onMouseLeave={() => setShowLayers(false)}>
|
onMouseLeave={() => setShowLayers(false)}>
|
||||||
{([
|
{([
|
||||||
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
|
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
|
||||||
|
{ key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" },
|
||||||
{ key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
|
{ key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
|
||||||
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
|
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
|
||||||
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
|
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
|
||||||
@@ -1588,6 +1692,15 @@ export default function NetworkMapPage() {
|
|||||||
tBadge,
|
tBadge,
|
||||||
normalPx,
|
normalPx,
|
||||||
)
|
)
|
||||||
|
const flowHop = netflowByGreKey.get(edgeId)
|
||||||
|
const flowPos = edgeBadgePosition(
|
||||||
|
e.from.x,
|
||||||
|
e.from.y,
|
||||||
|
e.to.x,
|
||||||
|
e.to.y,
|
||||||
|
tBadge,
|
||||||
|
-normalPx - (normalPx === 0 ? 22 : 0),
|
||||||
|
)
|
||||||
function openGreDetail(ev: React.MouseEvent<SVGElement>) {
|
function openGreDetail(ev: React.MouseEvent<SVGElement>) {
|
||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
setSelectedGreEdge(e)
|
setSelectedGreEdge(e)
|
||||||
@@ -1598,7 +1711,7 @@ export default function NetworkMapPage() {
|
|||||||
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
|
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
|
||||||
<line
|
<line
|
||||||
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
|
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
|
||||||
stroke={ts.stroke} strokeWidth="1.5"
|
stroke={ts.stroke} strokeWidth={hopHasRate(flowHop) ? 2.6 : 1.5}
|
||||||
strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
|
strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
|
||||||
opacity={ts.opacity}
|
opacity={ts.opacity}
|
||||||
/>
|
/>
|
||||||
@@ -1632,6 +1745,14 @@ export default function NetworkMapPage() {
|
|||||||
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
|
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showNetflow && hopHasRate(flowHop) && (
|
||||||
|
<NetflowRateBadge
|
||||||
|
mx={flowPos.mx}
|
||||||
|
my={flowPos.my}
|
||||||
|
hop={flowHop}
|
||||||
|
onOpen={openGreDetail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1663,14 +1784,16 @@ export default function NetworkMapPage() {
|
|||||||
const color = WAN_COLORS[edge.wanIdx] ?? "#888"
|
const color = WAN_COLORS[edge.wanIdx] ?? "#888"
|
||||||
const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
|
const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
|
||||||
const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17)
|
const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17)
|
||||||
|
const flowPos = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.38, 18)
|
||||||
const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
|
const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
|
||||||
|
const wanFlow = netflowByWanKey.get(wanJhEdgeMapKey(edge))
|
||||||
return (
|
return (
|
||||||
<g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
|
<g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
|
||||||
style={{ transition: "opacity 0.3s" }}>
|
style={{ transition: "opacity 0.3s" }}>
|
||||||
<line
|
<line
|
||||||
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
|
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={edge.active ? 2 : 1.2}
|
strokeWidth={edge.active ? (hopHasRate(wanFlow) ? 2.8 : 2) : 1.2}
|
||||||
strokeDasharray={edge.active ? "none" : "5 4"}
|
strokeDasharray={edge.active ? "none" : "5 4"}
|
||||||
opacity={edge.active ? 0.7 : 0.4}
|
opacity={edge.active ? 0.7 : 0.4}
|
||||||
filter={isHL ? `url(#glow-wan-${edge.wanIdx})` : undefined}
|
filter={isHL ? `url(#glow-wan-${edge.wanIdx})` : undefined}
|
||||||
@@ -1710,6 +1833,14 @@ export default function NetworkMapPage() {
|
|||||||
}
|
}
|
||||||
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
|
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
|
||||||
})()}
|
})()}
|
||||||
|
{showNetflow && hopHasRate(wanFlow) && (
|
||||||
|
<NetflowRateBadge
|
||||||
|
mx={flowPos.mx}
|
||||||
|
my={flowPos.my}
|
||||||
|
hop={wanFlow}
|
||||||
|
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1976,7 +2107,7 @@ export default function NetworkMapPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{bwMon ? "TX / RX (BT)" : "Скорость (модель)"}
|
{bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-mono font-semibold text-sky-400">
|
<span className="text-xs font-mono font-semibold text-sky-400">
|
||||||
{merged.dlMbps != null && merged.ulMbps != null
|
{merged.dlMbps != null && merged.ulMbps != null
|
||||||
@@ -1984,10 +2115,23 @@ export default function NetworkMapPage() {
|
|||||||
: "—"}
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||||
|
<span className="text-xs text-muted-foreground">Поток (NetFlow)</span>
|
||||||
|
<span className="text-xs font-mono font-semibold text-emerald-400">
|
||||||
|
{(() => {
|
||||||
|
const hop = netflowByGreKey.get(selectedEdgeId)
|
||||||
|
if (!hopHasRate(hop)) return "—"
|
||||||
|
return hop.bpsFwd > 0 && hop.bpsRev > 0
|
||||||
|
? formatNetflowDir(hop)
|
||||||
|
: formatNetflowRate(hop)
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground pt-2 leading-snug">
|
<p className="text-[10px] text-muted-foreground pt-2 leading-snug">
|
||||||
{merged.hasSpeedMonitor
|
{merged.hasSpeedMonitor
|
||||||
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам."
|
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. "
|
||||||
: "«Модель RTT» и «скорость» — демо до появления подходящей speed-пробы в «Мониторинг → скорость»."}
|
: "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "}
|
||||||
|
Поток — живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2168,12 +2312,26 @@ export default function NetworkMapPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip],
|
{[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip],
|
||||||
["BW", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => (
|
["Канал", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => (
|
||||||
<div key={k} className="flex items-center justify-between">
|
<div key={k} className="flex items-center justify-between">
|
||||||
<span className="text-[10px] text-muted-foreground">{k}</span>
|
<span className="text-[10px] text-muted-foreground">{k}</span>
|
||||||
<span className="text-[10px] font-mono">{v}</span>
|
<span className="text-[10px] font-mono">{v}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{(() => {
|
||||||
|
const hop = netflowByWanKey.get(`${selected.id}\t${wIdx}`)
|
||||||
|
if (!hopHasRate(hop)) return null
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-muted-foreground">Поток</span>
|
||||||
|
<span className="text-[10px] font-mono text-emerald-400">
|
||||||
|
{hop.bpsFwd > 0 && hop.bpsRev > 0
|
||||||
|
? formatNetflowDir(hop)
|
||||||
|
: formatNetflowRate(hop)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{myEdges.length > 0 && (
|
{myEdges.length > 0 && (
|
||||||
<div className="mt-2 pt-2 border-t border-border/40">
|
<div className="mt-2 pt-2 border-t border-border/40">
|
||||||
@@ -2230,9 +2388,11 @@ export default function NetworkMapPage() {
|
|||||||
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
|
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
|
||||||
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
||||||
const pc = pingColor(merged.pingMs)
|
const pc = pingColor(merged.pingMs)
|
||||||
|
const greFlow = netflowByGreKey.get(tunnelPanelKey)
|
||||||
const showMetrics =
|
const showMetrics =
|
||||||
merged.pingMs != null ||
|
merged.pingMs != null ||
|
||||||
(merged.dlMbps != null && merged.ulMbps != null)
|
(merged.dlMbps != null && merged.ulMbps != null) ||
|
||||||
|
hopHasRate(greFlow)
|
||||||
return (
|
return (
|
||||||
<div key={tunnelPanelKey} 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">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@@ -2272,6 +2432,11 @@ export default function NetworkMapPage() {
|
|||||||
↓{merged.dlMbps} ↑{merged.ulMbps}
|
↓{merged.dlMbps} ↑{merged.ulMbps}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{hopHasRate(greFlow) && (
|
||||||
|
<span className="text-[9px] ml-1.5 text-emerald-400">
|
||||||
|
{formatNetflowRate(greFlow)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
||||||
|
import { purgeTrafficFlowData } from "@/shared/api/traffic-flow"
|
||||||
|
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
|
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
|
||||||
@@ -148,6 +150,8 @@ export default function SettingsPage() {
|
|||||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||||
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
||||||
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
||||||
|
const [dbPurgeOpen, setDbPurgeOpen] = useState(false)
|
||||||
|
const [dbPurgeBusy, setDbPurgeBusy] = useState(false)
|
||||||
|
|
||||||
// notifications
|
// notifications
|
||||||
const [notifEmail, setNotifEmail] = useState(true)
|
const [notifEmail, setNotifEmail] = useState(true)
|
||||||
@@ -266,6 +270,20 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [backendUrl, dbRestoreFile, systemDbAvailable])
|
}, [backendUrl, dbRestoreFile, systemDbAvailable])
|
||||||
|
|
||||||
|
const handleNetflowPurgeConfirm = useCallback(async () => {
|
||||||
|
if (!systemDbAvailable) return
|
||||||
|
setDbPurgeBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await purgeTrafficFlowData(backendUrl)
|
||||||
|
toast.success(formatFlowPurgeResult(result))
|
||||||
|
setDbPurgeOpen(false)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
|
||||||
|
} finally {
|
||||||
|
setDbPurgeBusy(false)
|
||||||
|
}
|
||||||
|
}, [backendUrl, systemDbAvailable])
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||||
|
|
||||||
@@ -417,7 +435,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="База данных приложения"
|
title="База данных приложения"
|
||||||
description="Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик сбора данных приостанавливается."
|
description="Резервная копия SQLite бекенда и сброс таблиц NetFlow. На время операции планировщик и коллектор IPFIX приостанавливаются. Preview: https://reui.io/preview/base/settings-16"
|
||||||
contentClassName="divide-y px-5"
|
contentClassName="divide-y px-5"
|
||||||
>
|
>
|
||||||
{!systemDbAvailable && (
|
{!systemDbAvailable && (
|
||||||
@@ -434,7 +452,7 @@ export default function SettingsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||||||
onClick={() => { void handleSystemDatabaseBackup() }}
|
onClick={() => { void handleSystemDatabaseBackup() }}
|
||||||
>
|
>
|
||||||
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
|
||||||
@@ -450,7 +468,7 @@ export default function SettingsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||||||
onClick={() => setDbRestoreDialogOpen(true)}
|
onClick={() => setDbRestoreDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<UploadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
@@ -461,6 +479,21 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="Сбросить данные NetFlow"
|
||||||
|
description="Удалит сессии и агрегаты из SQLite, затем VACUUM. Ключи WG и пиры не трогает"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy || dbPurgeBusy}
|
||||||
|
onClick={() => setDbPurgeOpen(true)}
|
||||||
|
>
|
||||||
|
{dbPurgeBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <TrashIcon className="size-4" />}
|
||||||
|
{dbPurgeBusy ? "Сброс…" : "Сбросить"}
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
@@ -893,6 +926,13 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<NetflowPurgeConfirm
|
||||||
|
open={dbPurgeOpen}
|
||||||
|
busy={dbPurgeBusy}
|
||||||
|
onConfirm={() => { void handleNetflowPurgeConfirm() }}
|
||||||
|
onCancel={() => { if (!dbPurgeBusy) setDbPurgeOpen(false) }}
|
||||||
|
/>
|
||||||
|
|
||||||
<FileImportDialog
|
<FileImportDialog
|
||||||
open={dbRestoreDialogOpen}
|
open={dbRestoreDialogOpen}
|
||||||
onOpenChange={setDbRestoreDialogOpen}
|
onOpenChange={setDbRestoreDialogOpen}
|
||||||
|
|||||||
@@ -103,9 +103,14 @@ function monthlyToAnalytics(m: FlowMonthlyDto): FlowAnalyticsDto {
|
|||||||
services: m.services,
|
services: m.services,
|
||||||
mapEdges: [],
|
mapEdges: [],
|
||||||
conversationsList: [],
|
conversationsList: [],
|
||||||
|
paths: [],
|
||||||
ifaces: [],
|
ifaces: [],
|
||||||
live: false,
|
live: false,
|
||||||
degraded: false,
|
degraded: false,
|
||||||
|
bytesPayload: m.bytes,
|
||||||
|
bytesOverlay: 0,
|
||||||
|
bytesMesh: 0,
|
||||||
|
bytesWire: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,6 +811,8 @@ export default function TrafficPage() {
|
|||||||
const [flowAnalytics, setFlowAnalytics] = useState<FlowAnalyticsDto | null>(null)
|
const [flowAnalytics, setFlowAnalytics] = useState<FlowAnalyticsDto | null>(null)
|
||||||
const [flowIface, setFlowIface] = useState("__all__")
|
const [flowIface, setFlowIface] = useState("__all__")
|
||||||
const [flowDedup, setFlowDedup] = useState(true)
|
const [flowDedup, setFlowDedup] = useState(true)
|
||||||
|
const [flowExcludeMesh, setFlowExcludeMesh] = useState(true)
|
||||||
|
const [flowExcludeOverlay, setFlowExcludeOverlay] = useState(true)
|
||||||
const [overlayOpen, setOverlayOpen] = useState(false)
|
const [overlayOpen, setOverlayOpen] = useState(false)
|
||||||
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
|
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
|
||||||
const effectiveMode: GroupMode = groupMode
|
const effectiveMode: GroupMode = groupMode
|
||||||
@@ -824,6 +831,8 @@ export default function TrafficPage() {
|
|||||||
userId: flowScope === "users" ? selectedId : undefined,
|
userId: flowScope === "users" ? selectedId : undefined,
|
||||||
iface: flowIface,
|
iface: flowIface,
|
||||||
dedup: flowDedup,
|
dedup: flowDedup,
|
||||||
|
excludeMesh: flowExcludeMesh,
|
||||||
|
excludeOverlay: flowExcludeOverlay,
|
||||||
})
|
})
|
||||||
|
|
||||||
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||||||
@@ -961,8 +970,10 @@ export default function TrafficPage() {
|
|||||||
userId: flowScope === "users" ? selectedId : undefined,
|
userId: flowScope === "users" ? selectedId : undefined,
|
||||||
iface: flowIface,
|
iface: flowIface,
|
||||||
dedup: flowDedup,
|
dedup: flowDedup,
|
||||||
|
excludeMesh: flowExcludeMesh,
|
||||||
|
excludeOverlay: flowExcludeOverlay,
|
||||||
}).then(setFlowAnalytics).catch(() => setFlowAnalytics(null))
|
}).then(setFlowAnalytics).catch(() => setFlowAnalytics(null))
|
||||||
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, backendUrl])
|
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFlowIface("__all__")
|
setFlowIface("__all__")
|
||||||
@@ -1328,6 +1339,10 @@ export default function TrafficPage() {
|
|||||||
onIface={setFlowIface}
|
onIface={setFlowIface}
|
||||||
dedup={flowDedup}
|
dedup={flowDedup}
|
||||||
onDedup={setFlowDedup}
|
onDedup={setFlowDedup}
|
||||||
|
excludeMesh={flowExcludeMesh}
|
||||||
|
onExcludeMesh={setFlowExcludeMesh}
|
||||||
|
excludeOverlay={flowExcludeOverlay}
|
||||||
|
onExcludeOverlay={setFlowExcludeOverlay}
|
||||||
liveHint={displayedFlow?.live ? "live" : undefined}
|
liveHint={displayedFlow?.live ? "live" : undefined}
|
||||||
emptyHint={flowEmptyHint(flowStats, collectorAlive)}
|
emptyHint={flowEmptyHint(flowStats, collectorAlive)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
"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:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||||
"test:traffic-rate": "tsx src/services/traffic-rate.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-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-hardening.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-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:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ CREATE TABLE IF NOT EXISTS flow_buckets (
|
|||||||
bytes INTEGER NOT NULL DEFAULT 0,
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
packets INTEGER NOT NULL DEFAULT 0,
|
packets INTEGER NOT NULL DEFAULT 0,
|
||||||
in_iface TEXT NOT NULL DEFAULT '',
|
in_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
out_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
next_hop TEXT NOT NULL DEFAULT '',
|
||||||
|
flow_start_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
flow_end_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
||||||
@@ -889,6 +893,15 @@ WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const flowCols = sqlite.prepare(`PRAGMA table_info('flow_buckets')`).all() as Array<{ name?: string }>
|
||||||
|
const names = new Set(flowCols.map((c) => c.name))
|
||||||
|
if (!names.has("out_iface")) sqlite.exec(`ALTER TABLE flow_buckets ADD COLUMN out_iface TEXT NOT NULL DEFAULT ''`)
|
||||||
|
if (!names.has("next_hop")) sqlite.exec(`ALTER TABLE flow_buckets ADD COLUMN next_hop TEXT NOT NULL DEFAULT ''`)
|
||||||
|
if (!names.has("flow_start_ms")) sqlite.exec(`ALTER TABLE flow_buckets ADD COLUMN flow_start_ms INTEGER NOT NULL DEFAULT 0`)
|
||||||
|
if (!names.has("flow_end_ms")) sqlite.exec(`ALTER TABLE flow_buckets ADD COLUMN flow_end_ms INTEGER NOT NULL DEFAULT 0`)
|
||||||
|
}
|
||||||
|
|
||||||
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
||||||
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
||||||
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
||||||
@@ -952,6 +965,19 @@ export let db = drizzle(sqlite, { schema })
|
|||||||
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
||||||
export let sqliteDatabase: SqliteHandle = sqlite
|
export let sqliteDatabase: SqliteHandle = sqlite
|
||||||
|
|
||||||
|
let sqliteExclusiveOp = false
|
||||||
|
|
||||||
|
export function beginSqliteExclusiveOp(): void {
|
||||||
|
if (sqliteExclusiveOp) {
|
||||||
|
throw new Error("Операция с базой данных уже выполняется")
|
||||||
|
}
|
||||||
|
sqliteExclusiveOp = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endSqliteExclusiveOp(): void {
|
||||||
|
sqliteExclusiveOp = false
|
||||||
|
}
|
||||||
|
|
||||||
export function reopenSqlite(): void {
|
export function reopenSqlite(): void {
|
||||||
try {
|
try {
|
||||||
sqlite.close()
|
sqlite.close()
|
||||||
|
|||||||
@@ -230,6 +230,10 @@ export const flowBuckets = sqliteTable("flow_buckets", {
|
|||||||
bytes: integer("bytes").notNull().default(0),
|
bytes: integer("bytes").notNull().default(0),
|
||||||
packets: integer("packets").notNull().default(0),
|
packets: integer("packets").notNull().default(0),
|
||||||
inIface: text("in_iface").notNull().default(""),
|
inIface: text("in_iface").notNull().default(""),
|
||||||
|
outIface: text("out_iface").notNull().default(""),
|
||||||
|
nextHop: text("next_hop").notNull().default(""),
|
||||||
|
flowStartMs: integer("flow_start_ms").notNull().default(0),
|
||||||
|
flowEndMs: integer("flow_end_ms").notNull().default(0),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
uniqueIndex("idx_flow_buckets_unique").on(
|
uniqueIndex("idx_flow_buckets_unique").on(
|
||||||
t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort, t.inIface,
|
t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort, t.inIface,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ assert.equal(
|
|||||||
permissionForRequest("GET", "/api/traffic/servers/1/live"),
|
permissionForRequest("GET", "/api/traffic/servers/1/live"),
|
||||||
"mm:traffic:read",
|
"mm:traffic:read",
|
||||||
)
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/traffic/flow/purge"),
|
||||||
|
"mm:traffic:write",
|
||||||
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
permissionForRequest("GET", "/api/unknown-thing"),
|
permissionForRequest("GET", "/api/unknown-thing"),
|
||||||
"mm:dashboard:read",
|
"mm:dashboard:read",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../services/traffic-flow-settings.js"
|
} from "../services/traffic-flow-settings.js"
|
||||||
import {
|
import {
|
||||||
getFlowListenerState,
|
getFlowListenerState,
|
||||||
|
purgeTrafficFlowStore,
|
||||||
startTrafficFlowListener,
|
startTrafficFlowListener,
|
||||||
listFlowTalkers,
|
listFlowTalkers,
|
||||||
} from "../services/traffic-flow-ingest.js"
|
} from "../services/traffic-flow-ingest.js"
|
||||||
@@ -23,8 +24,10 @@ import {
|
|||||||
listFlowExporters,
|
listFlowExporters,
|
||||||
safeBuildLiveFlowSample,
|
safeBuildLiveFlowSample,
|
||||||
} from "../services/traffic-flow-analytics.js"
|
} from "../services/traffic-flow-analytics.js"
|
||||||
|
import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js"
|
||||||
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
||||||
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
|
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
|
||||||
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
|
||||||
const LIVE_TICK_MS = 2000
|
const LIVE_TICK_MS = 2000
|
||||||
export const MAX_FLOW_LIVE_SUBSCRIBERS = 4
|
export const MAX_FLOW_LIVE_SUBSCRIBERS = 4
|
||||||
@@ -69,13 +72,23 @@ function parseDedup(raw: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function analyticsQuery(req: FastifyRequest) {
|
function analyticsQuery(req: FastifyRequest) {
|
||||||
const q = req.query as { range?: string; serverId?: string; userId?: string; iface?: string; dedup?: string }
|
const q = req.query as {
|
||||||
|
range?: string
|
||||||
|
serverId?: string
|
||||||
|
userId?: string
|
||||||
|
iface?: string
|
||||||
|
dedup?: string
|
||||||
|
excludeMesh?: string
|
||||||
|
excludeOverlay?: string
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
minutes: rangeToMinutes(q.range),
|
minutes: rangeToMinutes(q.range),
|
||||||
serverId: parseId(q.serverId),
|
serverId: parseId(q.serverId),
|
||||||
userId: q.userId?.trim() || undefined,
|
userId: q.userId?.trim() || undefined,
|
||||||
iface: q.iface?.trim() || undefined,
|
iface: q.iface?.trim() || undefined,
|
||||||
dedup: parseDedup(q.dedup),
|
dedup: parseDedup(q.dedup),
|
||||||
|
excludeMesh: parseDedup(q.excludeMesh),
|
||||||
|
excludeOverlay: parseDedup(q.excludeOverlay),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +174,35 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
return reply.send({ files: listTrafficFlowHostFiles() })
|
return reply.send({ files: listTrafficFlowHostFiles() })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post("/traffic/flow/purge", async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
const result = await purgeTrafficFlowStore()
|
||||||
|
appendEvent({
|
||||||
|
level: "warning",
|
||||||
|
eventType: "traffic.flow.purge",
|
||||||
|
sourceModule: "traffic",
|
||||||
|
title: "Сброшены данные NetFlow",
|
||||||
|
message: `Удалены сессии ${result.deleted.buckets}, minute ${result.deleted.minuteStats}, daily ${result.deleted.dailyDims}`,
|
||||||
|
entityType: "traffic_flow",
|
||||||
|
entityId: "purge",
|
||||||
|
payload: {
|
||||||
|
buckets: result.deleted.buckets,
|
||||||
|
minuteStats: result.deleted.minuteStats,
|
||||||
|
minuteDims: result.deleted.minuteDims,
|
||||||
|
dailyDims: result.deleted.dailyDims,
|
||||||
|
fileBytesBefore: result.fileBytesBefore,
|
||||||
|
fileBytesAfter: result.fileBytesAfter,
|
||||||
|
vacuumed: result.vacuumed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return reply.send(result)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
const status = message.includes("уже выполняется") ? 409 : 500
|
||||||
|
return reply.status(status).send({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.post("/traffic/flow/overlay", applyOverlayHandler)
|
app.post("/traffic/flow/overlay", applyOverlayHandler)
|
||||||
app.post("/traffic/flow-overlay", applyOverlayHandler)
|
app.post("/traffic/flow-overlay", applyOverlayHandler)
|
||||||
|
|
||||||
@@ -181,6 +223,10 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
return reply.send(buildFlowAnalytics(analyticsQuery(req)))
|
return reply.send(buildFlowAnalytics(analyticsQuery(req)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/flow/map-hops", async (req, reply) => {
|
||||||
|
return reply.send(buildFlowMapHops(analyticsQuery(req)))
|
||||||
|
})
|
||||||
|
|
||||||
app.get("/traffic/flow/monthly", async (req, reply) => {
|
app.get("/traffic/flow/monthly", async (req, reply) => {
|
||||||
const q = req.query as { month?: string; serverId?: string }
|
const q = req.query as { month?: string; serverId?: string }
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -200,6 +246,8 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
userId: query.userId,
|
userId: query.userId,
|
||||||
iface: query.iface,
|
iface: query.iface,
|
||||||
dedup: query.dedup,
|
dedup: query.dedup,
|
||||||
|
excludeMesh: query.excludeMesh,
|
||||||
|
excludeOverlay: query.excludeOverlay,
|
||||||
}
|
}
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const onClose = () => abort.abort()
|
const onClose = () => abort.abort()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os from "node:os"
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import Database from "better-sqlite3"
|
import Database from "better-sqlite3"
|
||||||
import { env } from "../config.js"
|
import { env } from "../config.js"
|
||||||
import { reopenSqlite, sqliteDatabase } from "../db/index.js"
|
import { beginSqliteExclusiveOp, endSqliteExclusiveOp, reopenSqlite, sqliteDatabase } from "../db/index.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./scheduler.js"
|
||||||
import {
|
import {
|
||||||
reattachFlowSqlite,
|
reattachFlowSqlite,
|
||||||
@@ -17,8 +17,6 @@ const MAX_RESTORE_BYTES = 512 * 1024 * 1024
|
|||||||
|
|
||||||
type SqliteHandle = InstanceType<typeof Database>
|
type SqliteHandle = InstanceType<typeof Database>
|
||||||
|
|
||||||
let operationInFlight = false
|
|
||||||
|
|
||||||
function fmtTimestamp(date = new Date()): string {
|
function fmtTimestamp(date = new Date()): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, "0")
|
const pad = (n: number) => String(n).padStart(2, "0")
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
|
||||||
@@ -38,10 +36,7 @@ function assertSqliteFile(buffer: Buffer): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function withDatabaseOperation<T>(fn: () => Promise<T> | T): Promise<T> {
|
async function withDatabaseOperation<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||||
if (operationInFlight) {
|
beginSqliteExclusiveOp()
|
||||||
throw new Error("Операция с базой данных уже выполняется")
|
|
||||||
}
|
|
||||||
operationInFlight = true
|
|
||||||
stopTrafficFlowListener()
|
stopTrafficFlowListener()
|
||||||
stopScheduler()
|
stopScheduler()
|
||||||
try {
|
try {
|
||||||
@@ -49,7 +44,7 @@ async function withDatabaseOperation<T>(fn: () => Promise<T> | T): Promise<T> {
|
|||||||
} finally {
|
} finally {
|
||||||
startTrafficFlowListener()
|
startTrafficFlowListener()
|
||||||
refreshScheduler()
|
refreshScheduler()
|
||||||
operationInFlight = false
|
endSqliteExclusiveOp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "./traffic-flow-ingest.js"
|
} from "./traffic-flow-ingest.js"
|
||||||
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
|
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
|
||||||
import { sqliteDatabase } from "../db/index.js"
|
import { sqliteDatabase } from "../db/index.js"
|
||||||
|
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||||
import { disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
|
import { disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||||
import {
|
import {
|
||||||
disableRipeEnqueueForTests,
|
disableRipeEnqueueForTests,
|
||||||
@@ -21,8 +22,18 @@ resetRipeCacheForTests()
|
|||||||
disableRipeEnqueueForTests()
|
disableRipeEnqueueForTests()
|
||||||
resetIfaceCacheForTests()
|
resetIfaceCacheForTests()
|
||||||
resetFlowRingsForTests()
|
resetFlowRingsForTests()
|
||||||
|
seedFlowTopologyForTests({
|
||||||
|
clientIfaces: new Map(),
|
||||||
|
clientByIface: new Map(),
|
||||||
|
enNodes: [],
|
||||||
|
enHosts: new Set(),
|
||||||
|
jhHosts: new Set(),
|
||||||
|
wanIfaces: new Map(),
|
||||||
|
plane: { clientIfaceNames: new Set(), enHosts: new Set(), jhHosts: new Set() },
|
||||||
|
})
|
||||||
rememberServerIfaces(7, [
|
rememberServerIfaces(7, [
|
||||||
{ ".id": "*2", name: "ether1" },
|
{ ".id": "*2", name: "ether1" },
|
||||||
|
{ ".id": "*B", name: "ether2" },
|
||||||
{ ".id": "*A", name: "wg-flow" },
|
{ ".id": "*A", name: "wg-flow" },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -36,7 +47,7 @@ ingestParsedFlowsForServerForTests(7, [
|
|||||||
bytes: 12_000,
|
bytes: 12_000,
|
||||||
packets: 10,
|
packets: 10,
|
||||||
inIface: "2",
|
inIface: "2",
|
||||||
outIface: "10",
|
outIface: "11",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "10.1.1.8",
|
src: "10.1.1.8",
|
||||||
@@ -82,6 +93,7 @@ resetFlowRingsForTests()
|
|||||||
resetIfaceCacheForTests()
|
resetIfaceCacheForTests()
|
||||||
rememberServerIfaces(7, [
|
rememberServerIfaces(7, [
|
||||||
{ ".id": "*2", name: "ether1" },
|
{ ".id": "*2", name: "ether1" },
|
||||||
|
{ ".id": "*B", name: "ether2" },
|
||||||
{ ".id": "*A", name: "wg-flow" },
|
{ ".id": "*A", name: "wg-flow" },
|
||||||
])
|
])
|
||||||
ingestParsedFlowsForServerForTests(7, [
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
@@ -94,7 +106,7 @@ ingestParsedFlowsForServerForTests(7, [
|
|||||||
bytes: 12_000,
|
bytes: 12_000,
|
||||||
packets: 10,
|
packets: 10,
|
||||||
inIface: "2",
|
inIface: "2",
|
||||||
outIface: "10",
|
outIface: "11",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "10.1.1.8",
|
src: "10.1.1.8",
|
||||||
@@ -104,7 +116,7 @@ ingestParsedFlowsForServerForTests(7, [
|
|||||||
dstPort: 443,
|
dstPort: 443,
|
||||||
bytes: 9_000,
|
bytes: 9_000,
|
||||||
packets: 9,
|
packets: 9,
|
||||||
inIface: "10",
|
inIface: "11",
|
||||||
outIface: "",
|
outIface: "",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -263,4 +275,147 @@ try {
|
|||||||
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day LIKE '2026-09-%'`).run()
|
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day LIKE '2026-09-%'`).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "NSK-SERVHOST-RTK" },
|
||||||
|
{ ".id": "*4", name: "gre-en-nsk" },
|
||||||
|
])
|
||||||
|
const topo: FlowTopology = {
|
||||||
|
clientIfaces: new Map([[7, new Set(["gre-client"])]]),
|
||||||
|
clientByIface: new Map([["7|gre-client", {
|
||||||
|
userId: "u1",
|
||||||
|
login: "alice",
|
||||||
|
name: "Alice",
|
||||||
|
serverId: 7,
|
||||||
|
interfaceName: "gre-client",
|
||||||
|
}]]),
|
||||||
|
enNodes: [{ id: 9, name: "NSK-SERVHOST-RTK", hosts: ["198.51.100.1"] }],
|
||||||
|
enHosts: new Set(["198.51.100.1"]),
|
||||||
|
jhHosts: new Set(["203.0.113.10"]),
|
||||||
|
wanIfaces: new Map(),
|
||||||
|
plane: {
|
||||||
|
clientIfaceNames: new Set(["gre-client"]),
|
||||||
|
enHosts: new Set(["198.51.100.1"]),
|
||||||
|
jhHosts: new Set(["203.0.113.10"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: "173.194.0.0/16",
|
||||||
|
asn: 15169,
|
||||||
|
country: "US",
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
holder: "GOOGLE",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst: "173.194.160.163",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 51234,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 12_000,
|
||||||
|
packets: 10,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "3",
|
||||||
|
nextHop: "198.51.100.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "203.0.113.10",
|
||||||
|
dst: "198.51.100.1",
|
||||||
|
proto: 47,
|
||||||
|
srcPort: 0,
|
||||||
|
dstPort: 0,
|
||||||
|
bytes: 5_000_000,
|
||||||
|
packets: 4000,
|
||||||
|
inIface: "4",
|
||||||
|
outIface: "4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst: "10.100.1.18",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 50000,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 8000,
|
||||||
|
packets: 8,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "2",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
const def = buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||||
|
assert.equal(def.bytes, 12_000)
|
||||||
|
assert.equal(def.bytesPayload, 12_000)
|
||||||
|
assert.equal(def.bytesOverlay, 5_000_000)
|
||||||
|
assert.equal(def.bytesMesh, 8000)
|
||||||
|
assert.equal(def.excludeOverlayApplied, true)
|
||||||
|
assert.equal(def.excludeMeshApplied, true)
|
||||||
|
assert.ok(!def.conversationsList.some((r) => r.proto === 47))
|
||||||
|
assert.equal(def.conversationsList[0]?.service, "Google")
|
||||||
|
assert.equal(def.conversationsList[0]?.category, "Веб")
|
||||||
|
assert.equal(def.conversationsList[0]?.clientName, "Alice")
|
||||||
|
assert.equal(def.conversationsList[0]?.enName, "NSK-SERVHOST-RTK")
|
||||||
|
assert.equal(def.conversationsList[0]?.plane, "payload")
|
||||||
|
const path = def.paths?.[0]
|
||||||
|
assert.ok(path)
|
||||||
|
assert.equal(path.clientName, "Alice")
|
||||||
|
assert.equal(path.enName, "NSK-SERVHOST-RTK")
|
||||||
|
assert.equal(path.dst, "173.194.160.163")
|
||||||
|
const withAll = buildFlowAnalytics({ minutes: 5, serverId: 7, excludeOverlay: false, excludeMesh: false })
|
||||||
|
assert.equal(withAll.bytes, 12_000 + 5_000_000 + 8000)
|
||||||
|
assert.ok(withAll.conversationsList.some((r) => r.plane === "overlay"))
|
||||||
|
assert.ok(withAll.conversationsList.some((r) => r.plane === "client_mesh"))
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
const sidRow = sqliteDatabase.prepare(`SELECT id FROM servers LIMIT 1`).get() as { id?: number } | undefined
|
||||||
|
if (sidRow?.id) {
|
||||||
|
const sid = sidRow.id
|
||||||
|
rememberServerIfaces(sid, [{ ".id": "*4", name: "gre-en-nsk" }])
|
||||||
|
ingestParsedFlowsForServerForTests(sid, [{
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 1,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 100,
|
||||||
|
packets: 1,
|
||||||
|
inIface: "4",
|
||||||
|
outIface: "4",
|
||||||
|
}])
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO traffic_samples (server_id, interface_name, sampled_at, rx_bytes, tx_bytes, rx_bps, tx_bps)
|
||||||
|
VALUES (?, 'gre-en-nsk', datetime('now'), 9000000, 1000000, 40000000, 2000000)
|
||||||
|
`).run(sid)
|
||||||
|
try {
|
||||||
|
const wire = buildFlowAnalytics({ minutes: 5, serverId: sid })
|
||||||
|
assert.ok((wire.bpsWire ?? 0) >= 40_000_000)
|
||||||
|
assert.notEqual(wire.bpsWire, (wire.bytes * 8) / 300)
|
||||||
|
} finally {
|
||||||
|
sqliteDatabase.prepare(`DELETE FROM traffic_samples WHERE server_id = ? AND interface_name = 'gre-en-nsk'`).run(sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
}
|
||||||
|
|
||||||
console.log("traffic-flow-analytics.test.ts: ok")
|
console.log("traffic-flow-analytics.test.ts: ok")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
FlowExportersDto,
|
FlowExportersDto,
|
||||||
FlowMapEdge,
|
FlowMapEdge,
|
||||||
FlowMonthlyDto,
|
FlowMonthlyDto,
|
||||||
|
FlowPathRow,
|
||||||
FlowTalkerDto,
|
FlowTalkerDto,
|
||||||
} from "@mmapp/contracts/traffic-flow"
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
import { protoName } from "./traffic-flow-parse.js"
|
import { protoName } from "./traffic-flow-parse.js"
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
listFlowRowsForWindow,
|
listFlowRowsForWindow,
|
||||||
type PendingFlowRow,
|
type PendingFlowRow,
|
||||||
} from "./traffic-flow-ingest.js"
|
} from "./traffic-flow-ingest.js"
|
||||||
import { MAX_PENDING } from "./traffic-flow-engine.js"
|
import { MAX_PENDING, RING_OVERLAY } from "./traffic-flow-engine.js"
|
||||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||||
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||||
@@ -28,6 +29,14 @@ import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
|
|||||||
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
||||||
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
|
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
|
||||||
import { isIsoCountry } from "./traffic-flow-brands.js"
|
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||||
|
import { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-planes.js"
|
||||||
|
import {
|
||||||
|
enGreIfaceNames,
|
||||||
|
latestWireBps,
|
||||||
|
loadFlowTopology,
|
||||||
|
resolveClient,
|
||||||
|
resolveEn,
|
||||||
|
} from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
export const LIVE_ANALYTICS_MINUTES = 5
|
export const LIVE_ANALYTICS_MINUTES = 5
|
||||||
const LIVE_DEGRADED_PENDING = Math.floor(MAX_PENDING * 0.8)
|
const LIVE_DEGRADED_PENDING = Math.floor(MAX_PENDING * 0.8)
|
||||||
@@ -39,6 +48,10 @@ export interface FlowAnalyticsQuery {
|
|||||||
iface?: string
|
iface?: string
|
||||||
/** Default true: один 5-tuple = max байт по ifaces. */
|
/** Default true: один 5-tuple = max байт по ifaces. */
|
||||||
dedup?: boolean
|
dedup?: boolean
|
||||||
|
/** Default true: скрыть GRE/WG между клиентами JH. */
|
||||||
|
excludeMesh?: boolean
|
||||||
|
/** Default true: скрыть overlay GRE/ESP JH↔EN из payload KPI. */
|
||||||
|
excludeOverlay?: boolean
|
||||||
skipHeavy?: boolean
|
skipHeavy?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +151,9 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
|||||||
const countryById = new Map(serverRows.map((s) => [s.id, (s.country || "").toUpperCase() || "UN"]))
|
const countryById = new Map(serverRows.map((s) => [s.id, (s.country || "").toUpperCase() || "UN"]))
|
||||||
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||||
const wantDedup = q.dedup !== false && !ifaceFilter
|
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||||
|
const excludeMesh = q.excludeMesh !== false
|
||||||
|
const excludeOverlay = q.excludeOverlay !== false
|
||||||
|
const topo = loadFlowTopology()
|
||||||
|
|
||||||
refreshFlowCatalogInBackground()
|
refreshFlowCatalogInBackground()
|
||||||
|
|
||||||
@@ -150,16 +166,37 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
|||||||
const countries = new Map<string, { bytes: number; packets: number; label?: string }>()
|
const countries = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||||
const categories = new Map<string, { bytes: number; packets: number; label?: string }>()
|
const categories = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||||
const services = new Map<string, { bytes: number; packets: number; label?: string }>()
|
const services = new Map<string, { bytes: number; packets: number; label?: string }>()
|
||||||
const conv = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
const conv = new Map<string, FlowTalkerDto & { rawBytes: number; flowStartMs: number; flowEndMs: number }>()
|
||||||
const edgeAcc = new Map<string, FlowMapEdge & { catBytes: Map<string, number> }>()
|
const edgeAcc = new Map<string, FlowMapEdge & { catBytes: Map<string, number> }>()
|
||||||
|
const pathAcc = new Map<string, FlowPathRow>()
|
||||||
const srcs = new Set<string>()
|
const srcs = new Set<string>()
|
||||||
const dsts = new Set<string>()
|
const dsts = new Set<string>()
|
||||||
const matched: PendingFlowRow[] = []
|
const matched: PendingFlowRow[] = []
|
||||||
const skipHeavy = Boolean(q.skipHeavy)
|
const skipHeavy = Boolean(q.skipHeavy)
|
||||||
|
let bytesPayload = 0
|
||||||
|
let bytesOverlay = 0
|
||||||
|
let bytesMesh = 0
|
||||||
|
const ifacesForWire = new Set<string>()
|
||||||
|
|
||||||
for (const r of raw) {
|
for (const r of raw) {
|
||||||
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||||
|
const outResolved = resolveIfaceName(r.serverId, r.outIface)
|
||||||
if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue
|
if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue
|
||||||
|
ifacesForWire.add(resolved.name)
|
||||||
|
if (outResolved.name && outResolved.name !== "—") ifacesForWire.add(outResolved.name)
|
||||||
|
const plane = classifyFlowPlane({
|
||||||
|
src: r.src,
|
||||||
|
dst: r.dst,
|
||||||
|
proto: r.proto,
|
||||||
|
srcPort: r.srcPort,
|
||||||
|
dstPort: r.dstPort,
|
||||||
|
inIface: resolved.name,
|
||||||
|
outIface: outResolved.name,
|
||||||
|
}, topo.plane)
|
||||||
|
if (plane === "payload") bytesPayload += r.bytes
|
||||||
|
else if (plane === "overlay") bytesOverlay += r.bytes
|
||||||
|
else if (plane === "client_mesh") bytesMesh += r.bytes
|
||||||
|
if (!shouldKeepPlane(plane, { excludeMesh, excludeOverlay })) continue
|
||||||
matched.push(r)
|
matched.push(r)
|
||||||
|
|
||||||
const ifaceKey = resolved.name
|
const ifaceKey = resolved.name
|
||||||
@@ -200,6 +237,18 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!skipHeavy) {
|
if (!skipHeavy) {
|
||||||
|
const outResolved = resolveIfaceName(r.serverId, r.outIface)
|
||||||
|
const plane = classifyFlowPlane({
|
||||||
|
src: r.src,
|
||||||
|
dst: r.dst,
|
||||||
|
proto: r.proto,
|
||||||
|
srcPort: r.srcPort,
|
||||||
|
dstPort: r.dstPort,
|
||||||
|
inIface: resolved.name,
|
||||||
|
outIface: outResolved.name,
|
||||||
|
}, topo.plane)
|
||||||
|
const client = resolveClient(topo, r.serverId, resolved.name)
|
||||||
|
const en = resolveEn(topo, r.nextHop, outResolved.name)
|
||||||
const ckey = wantDedup
|
const ckey = wantDedup
|
||||||
? flowTupleKey(r)
|
? flowTupleKey(r)
|
||||||
: `${flowTupleKey(r)}|${r.inIface}`
|
: `${flowTupleKey(r)}|${r.inIface}`
|
||||||
@@ -208,6 +257,8 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
|||||||
prev.rawBytes += r.bytes
|
prev.rawBytes += r.bytes
|
||||||
prev.bytes += r.bytes
|
prev.bytes += r.bytes
|
||||||
prev.packets += r.packets
|
prev.packets += r.packets
|
||||||
|
if (r.flowStartMs && (!prev.flowStartMs || r.flowStartMs < prev.flowStartMs)) prev.flowStartMs = r.flowStartMs
|
||||||
|
if (r.flowEndMs > prev.flowEndMs) prev.flowEndMs = r.flowEndMs
|
||||||
} else {
|
} else {
|
||||||
conv.set(ckey, {
|
conv.set(ckey, {
|
||||||
serverId: String(r.serverId),
|
serverId: String(r.serverId),
|
||||||
@@ -223,12 +274,48 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
|||||||
bps: 0,
|
bps: 0,
|
||||||
inIface: resolved.name,
|
inIface: resolved.name,
|
||||||
inIfaceIndex: resolved.index,
|
inIfaceIndex: resolved.index,
|
||||||
|
outIface: outResolved.name !== "—" ? outResolved.name : undefined,
|
||||||
|
nextHop: r.nextHop || undefined,
|
||||||
application: app,
|
application: app,
|
||||||
category: classified.category,
|
category: classified.category,
|
||||||
service: classified.service,
|
service: classified.service,
|
||||||
dstCountry: dstCountry || undefined,
|
dstCountry: dstCountry || undefined,
|
||||||
dstAsn: ripe?.asn || undefined,
|
dstAsn: ripe?.asn || undefined,
|
||||||
|
clientId: client?.userId,
|
||||||
|
clientName: client?.name,
|
||||||
|
enId: en ? String(en.id) : undefined,
|
||||||
|
enName: en?.name,
|
||||||
|
plane,
|
||||||
rawBytes: r.bytes,
|
rawBytes: r.bytes,
|
||||||
|
flowStartMs: r.flowStartMs ?? 0,
|
||||||
|
flowEndMs: r.flowEndMs ?? 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathKey = `${client?.userId || "unknown"}|${r.serverId}|${en?.id || ""}|${r.dst}|${resolved.name}`
|
||||||
|
const pathPrev = pathAcc.get(pathKey)
|
||||||
|
if (pathPrev) {
|
||||||
|
pathPrev.bytes += r.bytes
|
||||||
|
pathPrev.packets += r.packets
|
||||||
|
} else {
|
||||||
|
pathAcc.set(pathKey, {
|
||||||
|
id: pathKey,
|
||||||
|
clientId: client?.userId || "unknown",
|
||||||
|
clientName: client?.name || "Неизвестный клиент",
|
||||||
|
ifaces: client ? [...(topo.clientIfaces.get(r.serverId) ?? [resolved.name])].join(", ") : resolved.name,
|
||||||
|
serverId: String(r.serverId),
|
||||||
|
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||||
|
inIface: resolved.name,
|
||||||
|
outIface: outResolved.name !== "—" ? outResolved.name : "",
|
||||||
|
enId: en ? String(en.id) : "",
|
||||||
|
enName: en?.name || "",
|
||||||
|
dst: r.dst,
|
||||||
|
service: classified.service,
|
||||||
|
category: classified.category,
|
||||||
|
plane,
|
||||||
|
bytes: r.bytes,
|
||||||
|
packets: r.packets,
|
||||||
|
bps: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,10 +348,17 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
|||||||
enqueueRipeMisses(dsts)
|
enqueueRipeMisses(dsts)
|
||||||
|
|
||||||
const conversationsList = [...conv.values()]
|
const conversationsList = [...conv.values()]
|
||||||
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
.map((t) => {
|
||||||
|
const { rawBytes, flowStartMs, flowEndMs, ...rest } = t
|
||||||
|
return { ...rest, bps: flowBps(rawBytes, flowStartMs, flowEndMs, windowSec) }
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
.slice(0, top)
|
||||||
|
|
||||||
|
const paths: FlowPathRow[] = [...pathAcc.values()]
|
||||||
|
.map((p) => ({ ...p, bps: (p.bytes * 8) / windowSec }))
|
||||||
.sort((a, b) => b.bytes - a.bytes)
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
.slice(0, top)
|
.slice(0, top)
|
||||||
.map(({ rawBytes: _raw, ...rest }) => rest)
|
|
||||||
|
|
||||||
const topProto = topLabel(protocols)
|
const topProto = topLabel(protocols)
|
||||||
const topCategory = topLabel(categories)
|
const topCategory = topLabel(categories)
|
||||||
@@ -312,6 +406,12 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
|||||||
.sort((a, b) => b.bytes - a.bytes)
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
.slice(0, top)
|
.slice(0, top)
|
||||||
|
|
||||||
|
const overlayRing = ringServer
|
||||||
|
? getRingMbps(ringServer, RING_OVERLAY)
|
||||||
|
: { rxNow: 0, txNow: 0 }
|
||||||
|
const greNames = ringServer ? enGreIfaceNames(topo, ringServer, [...ifacesForWire]) : []
|
||||||
|
const wire = ringServer ? latestWireBps(ringServer, greNames) : { bps: 0, bytes: 0 }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bpsNow: (ring.rxNow + ring.txNow) * 1_000_000 || (totalBytes * 8) / windowSec,
|
bpsNow: (ring.rxNow + ring.txNow) * 1_000_000 || (totalBytes * 8) / windowSec,
|
||||||
bytes: totalBytes,
|
bytes: totalBytes,
|
||||||
@@ -342,10 +442,19 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
|||||||
services: topN(services, windowSec, top),
|
services: topN(services, windowSec, top),
|
||||||
mapEdges,
|
mapEdges,
|
||||||
conversationsList,
|
conversationsList,
|
||||||
|
paths,
|
||||||
ifaces: ifaceRows,
|
ifaces: ifaceRows,
|
||||||
live: listener.bound,
|
live: listener.bound,
|
||||||
dedupApplied: wantDedup,
|
dedupApplied: wantDedup,
|
||||||
degraded: skipHeavy,
|
degraded: skipHeavy,
|
||||||
|
bytesPayload,
|
||||||
|
bytesOverlay,
|
||||||
|
bytesMesh,
|
||||||
|
bytesWire: wire.bytes,
|
||||||
|
bpsOverlay: (overlayRing.rxNow + overlayRing.txNow) * 1_000_000 || (bytesOverlay * 8) / windowSec,
|
||||||
|
bpsWire: wire.bps,
|
||||||
|
excludeMeshApplied: excludeMesh,
|
||||||
|
excludeOverlayApplied: excludeOverlay,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ const WELL_KNOWN: Record<string, string> = {
|
|||||||
"17:500": "IKE",
|
"17:500": "IKE",
|
||||||
"17:4500": "NAT-T",
|
"17:4500": "NAT-T",
|
||||||
"17:1194": "OpenVPN",
|
"17:1194": "OpenVPN",
|
||||||
|
"17:443": "QUIC",
|
||||||
|
"17:853": "DNS",
|
||||||
|
"6:853": "DNS",
|
||||||
"17:51820": "WireGuard",
|
"17:51820": "WireGuard",
|
||||||
|
"17:13232": "WireGuard",
|
||||||
|
"17:51821": "WireGuard",
|
||||||
"17:4789": "VXLAN",
|
"17:4789": "VXLAN",
|
||||||
"17:4739": "IPFIX",
|
"17:4739": "IPFIX",
|
||||||
"17:2055": "NetFlow",
|
"17:2055": "NetFlow",
|
||||||
@@ -51,6 +56,7 @@ export function applicationName(proto: number, dstPort: number, srcPort = 0): st
|
|||||||
if (proto === 47) return "GRE"
|
if (proto === 47) return "GRE"
|
||||||
if (proto === 50) return "ESP"
|
if (proto === 50) return "ESP"
|
||||||
if (proto === 89) return "OSPF"
|
if (proto === 89) return "OSPF"
|
||||||
|
if (proto === 17 && (dstPort === 443 || srcPort === 443)) return "QUIC"
|
||||||
const dstKey = `${proto}:${dstPort}`
|
const dstKey = `${proto}:${dstPort}`
|
||||||
const srcKey = `${proto}:${srcPort}`
|
const srcKey = `${proto}:${srcPort}`
|
||||||
return WELL_KNOWN[dstKey] ?? WELL_KNOWN[srcKey] ?? `${protoName(proto)}/${dstPort || srcPort || "—"}`
|
return WELL_KNOWN[dstKey] ?? WELL_KNOWN[srcKey] ?? `${protoName(proto)}/${dstPort || srcPort || "—"}`
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ assert.equal(resolveRipeCountry("?", 0, ""), "")
|
|||||||
|
|
||||||
assert.equal(brandByAsn(13335)?.service, "Cloudflare")
|
assert.equal(brandByAsn(13335)?.service, "Cloudflare")
|
||||||
assert.equal(brandByAsn(13335)?.category, "CDN")
|
assert.equal(brandByAsn(13335)?.category, "CDN")
|
||||||
|
assert.equal(brandByAsn(15169)?.service, "Google")
|
||||||
|
assert.equal(brandByAsn(15169)?.category, "Веб")
|
||||||
|
assert.equal(lookupBrand("208.65.153.1", 0)?.service, "YouTube")
|
||||||
assert.equal(brandByAsn(32590)?.service, "Steam")
|
assert.equal(brandByAsn(32590)?.service, "Steam")
|
||||||
assert.equal(brandByAsn(32590)?.category, "Игры")
|
assert.equal(brandByAsn(32590)?.category, "Игры")
|
||||||
assert.equal(brandByAsn(401115)?.service, "ChatGPT")
|
assert.equal(brandByAsn(401115)?.service, "ChatGPT")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const ASN_BRANDS = new Map<number, BrandHit>([
|
|||||||
[32590, { service: "Steam", category: "Игры" }],
|
[32590, { service: "Steam", category: "Игры" }],
|
||||||
[2906, { service: "Netflix", category: "Видео / стриминг" }],
|
[2906, { service: "Netflix", category: "Видео / стриминг" }],
|
||||||
[40027, { service: "Netflix", category: "Видео / стриминг" }],
|
[40027, { service: "Netflix", category: "Видео / стриминг" }],
|
||||||
[15169, { service: "Google", category: "Видео / стриминг" }],
|
[15169, { service: "Google", category: "Веб" }],
|
||||||
[36040, { service: "YouTube", category: "Видео / стриминг" }],
|
[36040, { service: "YouTube", category: "Видео / стриминг" }],
|
||||||
[46489, { service: "Twitch", category: "Видео / стриминг" }],
|
[46489, { service: "Twitch", category: "Видео / стриминг" }],
|
||||||
[401115, { service: "ChatGPT", category: "ИИ" }],
|
[401115, { service: "ChatGPT", category: "ИИ" }],
|
||||||
@@ -59,6 +59,8 @@ const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
|
|||||||
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: { service: "Cloudflare", category: "CDN" } },
|
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: { service: "Cloudflare", category: "CDN" } },
|
||||||
{ cidr: "172.64.0.0/13", prefixLen: 13, hit: { service: "Cloudflare", category: "CDN" } },
|
{ cidr: "172.64.0.0/13", prefixLen: 13, hit: { service: "Cloudflare", category: "CDN" } },
|
||||||
{ cidr: "162.158.0.0/15", prefixLen: 15, hit: { service: "Cloudflare", category: "CDN" } },
|
{ cidr: "162.158.0.0/15", prefixLen: 15, hit: { service: "Cloudflare", category: "CDN" } },
|
||||||
|
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: { service: "YouTube", category: "Видео / стриминг" } },
|
||||||
|
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: { service: "YouTube", category: "Видео / стриминг" } },
|
||||||
].sort((a, b) => b.prefixLen - a.prefixLen)
|
].sort((a, b) => b.prefixLen - a.prefixLen)
|
||||||
|
|
||||||
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
|
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
|
import { applicationName } from "./traffic-flow-apps.js"
|
||||||
import { classifyFlowDst, disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
|
import { classifyFlowDst, disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||||
|
|
||||||
disableCatalogFetchForTests()
|
disableCatalogFetchForTests()
|
||||||
@@ -22,4 +23,38 @@ const amazonHolder = classifyFlowDst("203.0.113.50", 6, 443, 1, { prefix: "203.0
|
|||||||
assert.equal(amazonHolder.service, "Прочее")
|
assert.equal(amazonHolder.service, "Прочее")
|
||||||
assert.notEqual(amazonHolder.service, "AMAZON-AES - Amazon.com, Inc.")
|
assert.notEqual(amazonHolder.service, "AMAZON-AES - Amazon.com, Inc.")
|
||||||
|
|
||||||
|
const google = classifyFlowDst("173.194.160.163", 6, 443, 1, {
|
||||||
|
prefix: "173.194.0.0/16",
|
||||||
|
asn: 15169,
|
||||||
|
country: "US",
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
holder: "GOOGLE",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
assert.equal(google.service, "Google")
|
||||||
|
assert.equal(google.category, "Веб")
|
||||||
|
|
||||||
|
const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, {
|
||||||
|
prefix: "173.194.0.0/16",
|
||||||
|
asn: 15169,
|
||||||
|
country: "US",
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
holder: "YouTube LLC",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
assert.equal(youtube.service, "YouTube")
|
||||||
|
assert.equal(youtube.category, "Видео / стриминг")
|
||||||
|
|
||||||
|
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
|
||||||
|
assert.equal(gre.service, "GRE")
|
||||||
|
assert.equal(gre.category, "Туннель")
|
||||||
|
const esp = classifyFlowDst("198.51.100.1", 50, 0, 0, null)
|
||||||
|
assert.equal(esp.category, "Туннель")
|
||||||
|
assert.equal(applicationName(17, 443, 50000), "QUIC")
|
||||||
|
assert.equal(applicationName(17, 853, 50000), "DNS")
|
||||||
|
|
||||||
console.log("traffic-flow-classify.test.ts: ok")
|
console.log("traffic-flow-classify.test.ts: ok")
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ export function categoryFromPurpose(purpose: string, proto: number, dstPort: num
|
|||||||
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
|
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
|
||||||
if (/voip|discord|zoom/.test(p)) return "Голос"
|
if (/voip|discord|zoom/.test(p)) return "Голос"
|
||||||
if (/openai|chatgpt|\bai\b/.test(p)) return "ИИ"
|
if (/openai|chatgpt|\bai\b/.test(p)) return "ИИ"
|
||||||
|
if (/веб|web|google/.test(p)) return "Веб"
|
||||||
const app = applicationName(proto, dstPort, srcPort)
|
const app = applicationName(proto, dstPort, srcPort)
|
||||||
if (app === "DNS" || app === "SSH" || app === "BGP") return app
|
if (app === "DNS" || app === "SSH" || app === "BGP") return app
|
||||||
|
if (app === "GRE" || app === "ESP" || app === "WireGuard") return "Туннель"
|
||||||
return OTHER_SERVICE
|
return OTHER_SERVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +73,16 @@ export function classifyFlowDst(
|
|||||||
srcPort: number,
|
srcPort: number,
|
||||||
ripe: FlowIpMeta | null,
|
ripe: FlowIpMeta | null,
|
||||||
): FlowClassification {
|
): FlowClassification {
|
||||||
|
if (proto === 47) return { service: "GRE", category: "Туннель" }
|
||||||
|
if (proto === 50) return { service: "ESP", category: "Туннель" }
|
||||||
|
const app = applicationName(proto, dstPort, srcPort)
|
||||||
|
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
|
||||||
const hit = matchCidr(dst)
|
const hit = matchCidr(dst)
|
||||||
const brand = lookupBrand(dst, ripe?.asn ?? 0)
|
const holder = ripe?.holder ?? ""
|
||||||
|
const youtubeHolder = /youtube/i.test(holder)
|
||||||
|
const brand = youtubeHolder
|
||||||
|
? { service: "YouTube", category: "Видео / стриминг" }
|
||||||
|
: lookupBrand(dst, ripe?.asn ?? 0)
|
||||||
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
|
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
|
||||||
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
|
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
|
||||||
const category = hit
|
const category = hit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type Database from "better-sqlite3"
|
import type Database from "better-sqlite3"
|
||||||
import { parseFlowPacket, protoName, type ParsedFlow } from "./traffic-flow-parse.js"
|
import { normalizeParsedFlow, parseFlowPacket, protoName, type ParsedFlow, type ParsedFlowInput } from "./traffic-flow-parse.js"
|
||||||
|
import { classifyFlowPlaneLite } from "./traffic-flow-planes.js"
|
||||||
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
|
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
|
||||||
import { applicationName } from "./traffic-flow-apps.js"
|
import { applicationName } from "./traffic-flow-apps.js"
|
||||||
import { classifyFlowDst } from "./traffic-flow-classify.js"
|
import { classifyFlowDst } from "./traffic-flow-classify.js"
|
||||||
@@ -30,6 +31,9 @@ export interface PendingFlowRow {
|
|||||||
packets: number
|
packets: number
|
||||||
inIface: string
|
inIface: string
|
||||||
outIface: string
|
outIface: string
|
||||||
|
nextHop: string
|
||||||
|
flowStartMs: number
|
||||||
|
flowEndMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EngineStats {
|
export interface EngineStats {
|
||||||
@@ -108,8 +112,12 @@ function dayKey(bucketAt: string): string {
|
|||||||
return bucketAt.slice(0, 10)
|
return bucketAt.slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RING_PAYLOAD = "__all__"
|
||||||
|
export const RING_OVERLAY = "__overlay__"
|
||||||
|
export const RING_MESH = "__mesh__"
|
||||||
|
|
||||||
function ringKey(serverId: number, iface: string): string {
|
function ringKey(serverId: number, iface: string): string {
|
||||||
return `${serverId}\0${iface || "__all__"}`
|
return `${serverId}\0${iface || RING_PAYLOAD}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingKey(serverId: number, bucketAt: string, flow: ParsedFlow): string {
|
function pendingKey(serverId: number, bucketAt: string, flow: ParsedFlow): string {
|
||||||
@@ -135,10 +143,13 @@ function bumpTick(key: string, inBytes: number, outBytes: number): void {
|
|||||||
tickAccum.set(key, prev)
|
tickAccum.set(key, prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToTick(serverId: number, inIface: string, outIface: string, bytes: number): void {
|
function addToTick(serverId: number, flow: ParsedFlow, bytes: number): void {
|
||||||
bumpTick(ringKey(serverId, "__all__"), bytes, 0)
|
const plane = classifyFlowPlaneLite(flow)
|
||||||
if (inIface) bumpTick(ringKey(serverId, inIface), bytes, 0)
|
if (plane === "mgmt") return
|
||||||
if (outIface && outIface !== inIface) bumpTick(ringKey(serverId, outIface), 0, bytes)
|
const bucket = plane === "overlay" ? RING_OVERLAY : plane === "client_mesh" ? RING_MESH : RING_PAYLOAD
|
||||||
|
bumpTick(ringKey(serverId, bucket), bytes, 0)
|
||||||
|
if (flow.inIface) bumpTick(ringKey(serverId, flow.inIface), bytes, 0)
|
||||||
|
if (flow.outIface && flow.outIface !== flow.inIface) bumpTick(ringKey(serverId, flow.outIface), 0, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyRing(): { inBps: number[]; outBps: number[] } {
|
function emptyRing(): { inBps: number[]; outBps: number[] } {
|
||||||
@@ -221,11 +232,12 @@ export function getEngineStats(): EngineStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function queueParsedFlows(serverId: number, flows: ParsedFlow[]): void {
|
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
|
||||||
const bucketAt = minuteBucketIso()
|
const bucketAt = minuteBucketIso()
|
||||||
const ripeMisses: string[] = []
|
const ripeMisses: string[] = []
|
||||||
for (const flow of flows) {
|
for (const raw of flows) {
|
||||||
addToTick(serverId, flow.inIface, flow.outIface, flow.bytes)
|
const flow = normalizeParsedFlow(raw)
|
||||||
|
addToTick(serverId, flow, flow.bytes)
|
||||||
bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets)
|
bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets)
|
||||||
const ripe = lookupRipeCached(flow.dst)
|
const ripe = lookupRipeCached(flow.dst)
|
||||||
if (flow.dst && !ripe) ripeMisses.push(flow.dst)
|
if (flow.dst && !ripe) ripeMisses.push(flow.dst)
|
||||||
@@ -248,6 +260,12 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlow[]): void {
|
|||||||
if (prev) {
|
if (prev) {
|
||||||
prev.bytes += flow.bytes
|
prev.bytes += flow.bytes
|
||||||
prev.packets += flow.packets
|
prev.packets += flow.packets
|
||||||
|
if (flow.outIface && !prev.flow.outIface) prev.flow.outIface = flow.outIface
|
||||||
|
if (flow.nextHop && !prev.flow.nextHop) prev.flow.nextHop = flow.nextHop
|
||||||
|
if (flow.flowStartMs && (!prev.flow.flowStartMs || flow.flowStartMs < prev.flow.flowStartMs)) {
|
||||||
|
prev.flow.flowStartMs = flow.flowStartMs
|
||||||
|
}
|
||||||
|
if (flow.flowEndMs > (prev.flow.flowEndMs ?? 0)) prev.flow.flowEndMs = flow.flowEndMs
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (pending.size >= pendingCap) {
|
if (pending.size >= pendingCap) {
|
||||||
@@ -283,18 +301,22 @@ export function ingestDatagram(msg: Buffer, exporterIp: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toPendingRow(row: PendingEntry): PendingFlowRow {
|
function toPendingRow(row: PendingEntry): PendingFlowRow {
|
||||||
|
const flow = normalizeParsedFlow(row.flow)
|
||||||
return {
|
return {
|
||||||
serverId: row.serverId,
|
serverId: row.serverId,
|
||||||
bucketAt: row.bucketAt,
|
bucketAt: row.bucketAt,
|
||||||
src: row.flow.src || "0.0.0.0",
|
src: flow.src || "0.0.0.0",
|
||||||
dst: row.flow.dst || "0.0.0.0",
|
dst: flow.dst || "0.0.0.0",
|
||||||
proto: row.flow.proto,
|
proto: flow.proto,
|
||||||
srcPort: row.flow.srcPort,
|
srcPort: flow.srcPort,
|
||||||
dstPort: row.flow.dstPort,
|
dstPort: flow.dstPort,
|
||||||
bytes: row.bytes,
|
bytes: row.bytes,
|
||||||
packets: row.packets,
|
packets: row.packets,
|
||||||
inIface: row.flow.inIface,
|
inIface: flow.inIface,
|
||||||
outIface: row.flow.outIface,
|
outIface: flow.outIface,
|
||||||
|
nextHop: flow.nextHop,
|
||||||
|
flowStartMs: flow.flowStartMs,
|
||||||
|
flowEndMs: flow.flowEndMs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +326,10 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
|
|||||||
if (prev) {
|
if (prev) {
|
||||||
prev.bytes += row.bytes
|
prev.bytes += row.bytes
|
||||||
prev.packets += row.packets
|
prev.packets += row.packets
|
||||||
|
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
|
||||||
|
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
|
||||||
|
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
|
||||||
|
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
map.set(key, { ...row })
|
map.set(key, { ...row })
|
||||||
@@ -360,7 +386,7 @@ export function rollFlowRings(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRingMbps(serverId: number, iface = "__all__"): {
|
export function getRingMbps(serverId: number, iface = RING_PAYLOAD): {
|
||||||
rx: number[]
|
rx: number[]
|
||||||
tx: number[]
|
tx: number[]
|
||||||
rxNow: number
|
rxNow: number
|
||||||
@@ -597,14 +623,20 @@ export function flushPending(): void {
|
|||||||
|
|
||||||
const upsertFlow = handle.prepare(`
|
const upsertFlow = handle.prepare(`
|
||||||
INSERT INTO flow_buckets (
|
INSERT INTO flow_buckets (
|
||||||
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface
|
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface
|
@serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface, @outIface, @nextHop, @flowStartMs, @flowEndMs
|
||||||
)
|
)
|
||||||
ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
bytes = bytes + excluded.bytes,
|
bytes = bytes + excluded.bytes,
|
||||||
packets = packets + excluded.packets
|
packets = packets + excluded.packets,
|
||||||
|
out_iface = CASE WHEN excluded.out_iface != '' THEN excluded.out_iface ELSE out_iface END,
|
||||||
|
next_hop = CASE WHEN excluded.next_hop != '' THEN excluded.next_hop ELSE next_hop END,
|
||||||
|
flow_start_ms = CASE
|
||||||
|
WHEN excluded.flow_start_ms > 0 AND (flow_start_ms = 0 OR excluded.flow_start_ms < flow_start_ms)
|
||||||
|
THEN excluded.flow_start_ms ELSE flow_start_ms END,
|
||||||
|
flow_end_ms = MAX(flow_end_ms, excluded.flow_end_ms)
|
||||||
`)
|
`)
|
||||||
lastFlushUsedTransaction = false
|
lastFlushUsedTransaction = false
|
||||||
try {
|
try {
|
||||||
@@ -621,6 +653,10 @@ export function flushPending(): void {
|
|||||||
bytes: r.bytes,
|
bytes: r.bytes,
|
||||||
packets: r.packets,
|
packets: r.packets,
|
||||||
inIface: r.inIface,
|
inIface: r.inIface,
|
||||||
|
outIface: r.outIface,
|
||||||
|
nextHop: r.nextHop,
|
||||||
|
flowStartMs: r.flowStartMs,
|
||||||
|
flowEndMs: r.flowEndMs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -641,6 +677,10 @@ export function flushPending(): void {
|
|||||||
bytes: r.bytes,
|
bytes: r.bytes,
|
||||||
packets: r.packets,
|
packets: r.packets,
|
||||||
inIface: r.inIface,
|
inIface: r.inIface,
|
||||||
|
outIface: r.outIface,
|
||||||
|
nextHop: r.nextHop,
|
||||||
|
flowStartMs: r.flowStartMs,
|
||||||
|
flowEndMs: r.flowEndMs,
|
||||||
})
|
})
|
||||||
rowsStored += 1
|
rowsStored += 1
|
||||||
} catch {
|
} catch {
|
||||||
@@ -674,7 +714,7 @@ export function onEngineTick(): void {
|
|||||||
flushPending()
|
flushPending()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlow[]): void {
|
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]): void {
|
||||||
queueParsedFlows(serverId, flows)
|
queueParsedFlows(serverId, flows)
|
||||||
rollFlowRings()
|
rollFlowRings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Worker } from "node:worker_threads"
|
import { Worker } from "node:worker_threads"
|
||||||
|
import { existsSync, statSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
import { gte, sql } from "drizzle-orm"
|
import { gte, sql } from "drizzle-orm"
|
||||||
import { db, sqliteDatabase } from "../db/index.js"
|
import { beginSqliteExclusiveOp, db, endSqliteExclusiveOp, sqliteDatabase } from "../db/index.js"
|
||||||
import { env } from "../config.js"
|
import { env } from "../config.js"
|
||||||
import { flowBuckets, servers } from "../db/schema.js"
|
import { flowBuckets, servers } from "../db/schema.js"
|
||||||
import type { FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||||
import { protoName, type ParsedFlow } from "./traffic-flow-parse.js"
|
import { protoName, type ParsedFlowInput } from "./traffic-flow-parse.js"
|
||||||
import type { CollectorHeartbeat, ExporterMapPayload, MainToWorker, WorkerToMain } from "./traffic-flow-collector-ipc.js"
|
import type { CollectorHeartbeat, ExporterMapPayload, MainToWorker, WorkerToMain } from "./traffic-flow-collector-ipc.js"
|
||||||
import {
|
import {
|
||||||
attachEngineSqlite,
|
attachEngineSqlite,
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getTrafficFlowSettingsRow,
|
getTrafficFlowSettingsRow,
|
||||||
listHostPeers,
|
listHostPeers,
|
||||||
|
resetFlowIngestCounters,
|
||||||
} from "./traffic-flow-settings.js"
|
} from "./traffic-flow-settings.js"
|
||||||
import { applicationName } from "./traffic-flow-apps.js"
|
import { applicationName } from "./traffic-flow-apps.js"
|
||||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||||
@@ -267,6 +270,10 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
|
|||||||
if (prev) {
|
if (prev) {
|
||||||
prev.bytes += row.bytes
|
prev.bytes += row.bytes
|
||||||
prev.packets += row.packets
|
prev.packets += row.packets
|
||||||
|
if (row.outIface && !prev.outIface) prev.outIface = row.outIface
|
||||||
|
if (row.nextHop && !prev.nextHop) prev.nextHop = row.nextHop
|
||||||
|
if (row.flowStartMs && (!prev.flowStartMs || row.flowStartMs < prev.flowStartMs)) prev.flowStartMs = row.flowStartMs
|
||||||
|
if (row.flowEndMs > (prev.flowEndMs ?? 0)) prev.flowEndMs = row.flowEndMs
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
map.set(key, { ...row })
|
map.set(key, { ...row })
|
||||||
@@ -300,7 +307,10 @@ export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] {
|
|||||||
bytes: r.bytes,
|
bytes: r.bytes,
|
||||||
packets: r.packets,
|
packets: r.packets,
|
||||||
inIface: r.inIface,
|
inIface: r.inIface,
|
||||||
outIface: "",
|
outIface: r.outIface ?? "",
|
||||||
|
nextHop: r.nextHop ?? "",
|
||||||
|
flowStartMs: r.flowStartMs ?? 0,
|
||||||
|
flowEndMs: r.flowEndMs ?? 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!worker) {
|
if (!worker) {
|
||||||
@@ -394,7 +404,7 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlow[]) {
|
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlowInput[]) {
|
||||||
applyExporterCtxFromDb()
|
applyExporterCtxFromDb()
|
||||||
const serverId = resolveServerId(exporterIp)
|
const serverId = resolveServerId(exporterIp)
|
||||||
if (serverId == null) return
|
if (serverId == null) return
|
||||||
@@ -403,7 +413,7 @@ export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlow[
|
|||||||
flushPending()
|
flushPending()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlow[]) {
|
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]) {
|
||||||
engineIngestForServer(serverId, flows)
|
engineIngestForServer(serverId, flows)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +433,86 @@ export function flushPendingForTests(): void {
|
|||||||
flushPending()
|
flushPending()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tableCount(name: string): number {
|
||||||
|
const row = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM ${name}`).get() as { n: number }
|
||||||
|
return Number(row?.n) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbFileBytes(): number {
|
||||||
|
const resolved = path.resolve(process.cwd(), env.DATABASE_PATH)
|
||||||
|
if (!existsSync(resolved)) return 0
|
||||||
|
return statSync(resolved).size
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopWorkerProcessAsync(): Promise<void> {
|
||||||
|
if (restartTimer) {
|
||||||
|
clearTimeout(restartTimer)
|
||||||
|
restartTimer = null
|
||||||
|
}
|
||||||
|
if (!worker) return
|
||||||
|
const current = worker
|
||||||
|
worker = null
|
||||||
|
try {
|
||||||
|
current.postMessage({ type: "stop" })
|
||||||
|
await current.terminate()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет сессии, minute/daily rollup и сжимает SQLite. Ключи WG и пиры JH не трогает. */
|
||||||
|
export async function purgeTrafficFlowStore(): Promise<FlowPurgeDto> {
|
||||||
|
beginSqliteExclusiveOp()
|
||||||
|
try {
|
||||||
|
wantListen = false
|
||||||
|
await stopWorkerProcessAsync()
|
||||||
|
resetEngineForTests()
|
||||||
|
attachEngineSqlite(sqliteDatabase)
|
||||||
|
lastHeartbeat = null
|
||||||
|
state = { bound: false, address: null }
|
||||||
|
const fileBytesBefore = dbFileBytes()
|
||||||
|
const deleted = {
|
||||||
|
buckets: tableCount("flow_buckets"),
|
||||||
|
minuteStats: tableCount("flow_minute_stats"),
|
||||||
|
minuteDims: tableCount("flow_minute_dims"),
|
||||||
|
dailyDims: tableCount("flow_daily_dims"),
|
||||||
|
}
|
||||||
|
sqliteDatabase.exec(`
|
||||||
|
DELETE FROM flow_buckets;
|
||||||
|
DELETE FROM flow_minute_stats;
|
||||||
|
DELETE FROM flow_minute_dims;
|
||||||
|
DELETE FROM flow_daily_dims;
|
||||||
|
`)
|
||||||
|
resetFlowIngestCounters()
|
||||||
|
try {
|
||||||
|
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
let vacuumed = false
|
||||||
|
try {
|
||||||
|
sqliteDatabase.exec("VACUUM")
|
||||||
|
vacuumed = true
|
||||||
|
} catch {
|
||||||
|
vacuumed = false
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
deleted,
|
||||||
|
fileBytesBefore,
|
||||||
|
fileBytesAfter: dbFileBytes(),
|
||||||
|
vacuumed,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
startTrafficFlowListener()
|
||||||
|
} catch {
|
||||||
|
/* ingest мог остаться выключенным */
|
||||||
|
}
|
||||||
|
endSqliteExclusiveOp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { peekPendingFlows }
|
export { peekPendingFlows }
|
||||||
export { setPendingCapForTests } from "./traffic-flow-engine.js"
|
export { setPendingCapForTests } from "./traffic-flow-engine.js"
|
||||||
export { maybeRefreshIfaces, setRefreshIfacesForTests } from "./traffic-flow-ifaces.js"
|
export { maybeRefreshIfaces, setRefreshIfacesForTests } from "./traffic-flow-ifaces.js"
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
|
||||||
|
import {
|
||||||
|
ingestParsedFlowsForServerForTests,
|
||||||
|
resetFlowRingsForTests,
|
||||||
|
} from "./traffic-flow-ingest.js"
|
||||||
|
import { buildFlowMapHops } from "./traffic-flow-map-hops.js"
|
||||||
|
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||||
|
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||||
|
import {
|
||||||
|
disableRipeEnqueueForTests,
|
||||||
|
disableRipePersistForTests,
|
||||||
|
resetRipeCacheForTests,
|
||||||
|
} from "./traffic-flow-ripe.js"
|
||||||
|
|
||||||
|
disableCatalogFetchForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
|
||||||
|
const topo: FlowTopology = {
|
||||||
|
clientIfaces: new Map([[7, new Set(["gre-client"])]]),
|
||||||
|
clientByIface: new Map([["7|gre-client", {
|
||||||
|
userId: "u1",
|
||||||
|
login: "alice",
|
||||||
|
name: "Alice",
|
||||||
|
serverId: 7,
|
||||||
|
interfaceName: "gre-client",
|
||||||
|
}]]),
|
||||||
|
enNodes: [{ id: 9, name: "NSK-EN", hosts: ["198.51.100.1"] }],
|
||||||
|
enHosts: new Set(["198.51.100.1"]),
|
||||||
|
jhHosts: new Set(["203.0.113.10"]),
|
||||||
|
wanIfaces: new Map([[3, new Set(["ether1-rt"])]]),
|
||||||
|
plane: {
|
||||||
|
clientIfaceNames: new Set(["gre-client"]),
|
||||||
|
enHosts: new Set(["198.51.100.1"]),
|
||||||
|
jhHosts: new Set(["203.0.113.10"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
{ ".id": "*A", name: "wg-flow" },
|
||||||
|
])
|
||||||
|
rememberServerIfaces(3, [
|
||||||
|
{ ".id": "*1", name: "ether1-rt" },
|
||||||
|
])
|
||||||
|
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 51234,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 12_000,
|
||||||
|
packets: 10,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "3",
|
||||||
|
nextHop: "198.51.100.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "203.0.113.10",
|
||||||
|
dst: "198.51.100.1",
|
||||||
|
proto: 47,
|
||||||
|
srcPort: 0,
|
||||||
|
dstPort: 0,
|
||||||
|
bytes: 5_000_000,
|
||||||
|
packets: 4000,
|
||||||
|
inIface: "3",
|
||||||
|
outIface: "3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst: "10.100.1.18",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 50000,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 8000,
|
||||||
|
packets: 8,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "10.255.254.1",
|
||||||
|
dst: "10.255.254.2",
|
||||||
|
proto: 17,
|
||||||
|
srcPort: 4739,
|
||||||
|
dstPort: 2055,
|
||||||
|
bytes: 400,
|
||||||
|
packets: 2,
|
||||||
|
inIface: "10",
|
||||||
|
outIface: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
ingestParsedFlowsForServerForTests(3, [
|
||||||
|
{
|
||||||
|
src: "192.168.1.10",
|
||||||
|
dst: "8.8.4.4",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 40000,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 3000,
|
||||||
|
packets: 4,
|
||||||
|
inIface: "1",
|
||||||
|
outIface: "1",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const def = buildFlowMapHops({ minutes: 5 })
|
||||||
|
assert.equal(def.excludeOverlayApplied, true)
|
||||||
|
assert.equal(def.excludeMeshApplied, true)
|
||||||
|
assert.equal(def.dedupApplied, true)
|
||||||
|
assert.equal(def.windowSec, 300)
|
||||||
|
|
||||||
|
const payloadGre = def.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
|
||||||
|
assert.ok(payloadGre, "payload JH→EN hop")
|
||||||
|
assert.equal(payloadGre.bytes, 12_000)
|
||||||
|
assert.equal(payloadGre.bps, (12_000 * 8) / 300)
|
||||||
|
assert.equal(payloadGre.bpsFwd, (12_000 * 8) / 300)
|
||||||
|
assert.equal(payloadGre.iface, "gre-jh-en")
|
||||||
|
|
||||||
|
const greIface = def.hops.find((h) => h.kind === "iface" && h.iface === "gre-jh-en" && h.fromId === "7")
|
||||||
|
assert.ok(greIface)
|
||||||
|
assert.equal(greIface.bytes, 12_000)
|
||||||
|
assert.equal(greIface.bpsFwd, (12_000 * 8) / 300)
|
||||||
|
|
||||||
|
assert.ok(!def.hops.some((h) => h.bytes >= 5_000_000), "overlay GRE proto 47 excluded")
|
||||||
|
assert.ok(!def.hops.some((h) => h.iface === "wg-flow"), "mgmt wg-flow excluded")
|
||||||
|
const clientIngress = def.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
|
||||||
|
assert.ok(clientIngress, "payload ingress on client iface")
|
||||||
|
assert.equal(clientIngress.bytes, 12_000)
|
||||||
|
|
||||||
|
const wan = def.hops.find((h) => h.kind === "wan" && h.fromId === "3" && h.iface === "ether1-rt")
|
||||||
|
assert.ok(wan, "WAN hop from home-router")
|
||||||
|
assert.equal(wan.bytes, 3000)
|
||||||
|
|
||||||
|
const withAll = buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false })
|
||||||
|
const overlayIface = withAll.hops.find((h) => h.iface === "gre-jh-en" && h.fromId === "7")
|
||||||
|
assert.ok(overlayIface && overlayIface.bytes >= 5_000_000)
|
||||||
|
const meshIface = withAll.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
|
||||||
|
assert.ok(meshIface && meshIface.bytes >= 20_000)
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("traffic-flow-map-hops.test.ts: ok")
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { servers, userInterfaceBindings } from "../db/schema.js"
|
||||||
|
import { flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||||
|
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
||||||
|
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
|
||||||
|
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||||
|
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
|
||||||
|
import { loadFlowTopology, resolveEn } from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
|
export interface FlowMapHopsQuery {
|
||||||
|
minutes: number
|
||||||
|
serverId?: number
|
||||||
|
userId?: string
|
||||||
|
iface?: string
|
||||||
|
dedup?: boolean
|
||||||
|
excludeMesh?: boolean
|
||||||
|
excludeOverlay?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HopAcc {
|
||||||
|
fromId: string
|
||||||
|
fromLabel: string
|
||||||
|
toId: string
|
||||||
|
toLabel: string
|
||||||
|
kind: FlowMapHop["kind"]
|
||||||
|
iface?: string
|
||||||
|
bytes: number
|
||||||
|
bytesFwd: number
|
||||||
|
bytesRev: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
|
||||||
|
if (!userId) return null
|
||||||
|
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
|
||||||
|
const allow = new Map<number, Set<string>>()
|
||||||
|
for (const b of binds) {
|
||||||
|
const set = allow.get(b.serverId) ?? new Set<string>()
|
||||||
|
set.add(b.interfaceName)
|
||||||
|
allow.set(b.serverId, set)
|
||||||
|
}
|
||||||
|
return allow
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifaceUsable(name: string): boolean {
|
||||||
|
return Boolean(name) && name !== "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
function bump(acc: Map<string, HopAcc>, key: string, seed: Omit<HopAcc, "bytes" | "bytesFwd" | "bytesRev">, bytes: number, dir: "fwd" | "rev" | "both"): void {
|
||||||
|
const prev = acc.get(key)
|
||||||
|
const addFwd = dir === "fwd" || dir === "both" ? bytes : 0
|
||||||
|
const addRev = dir === "rev" || dir === "both" ? bytes : 0
|
||||||
|
if (prev) {
|
||||||
|
prev.bytes += bytes
|
||||||
|
prev.bytesFwd += addFwd
|
||||||
|
prev.bytesRev += addRev
|
||||||
|
if (seed.iface && !prev.iface) prev.iface = seed.iface
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acc.set(key, {
|
||||||
|
...seed,
|
||||||
|
bytes,
|
||||||
|
bytesFwd: addFwd,
|
||||||
|
bytesRev: addRev,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHop(a: HopAcc, windowSec: number): FlowMapHop {
|
||||||
|
return {
|
||||||
|
fromId: a.fromId,
|
||||||
|
fromLabel: a.fromLabel,
|
||||||
|
toId: a.toId,
|
||||||
|
toLabel: a.toLabel,
|
||||||
|
kind: a.kind,
|
||||||
|
...(a.iface ? { iface: a.iface } : {}),
|
||||||
|
bytes: a.bytes,
|
||||||
|
bps: (a.bytes * 8) / windowSec,
|
||||||
|
bpsFwd: (a.bytesFwd * 8) / windowSec,
|
||||||
|
bpsRev: (a.bytesRev * 8) / windowSec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */
|
||||||
|
export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
|
||||||
|
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 ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||||
|
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||||
|
const excludeMesh = q.excludeMesh !== false
|
||||||
|
const excludeOverlay = q.excludeOverlay !== false
|
||||||
|
const topo = loadFlowTopology()
|
||||||
|
|
||||||
|
const matched = []
|
||||||
|
for (const r of raw) {
|
||||||
|
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||||
|
const outResolved = resolveIfaceName(r.serverId, r.outIface)
|
||||||
|
if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue
|
||||||
|
const plane = classifyFlowPlane({
|
||||||
|
src: r.src,
|
||||||
|
dst: r.dst,
|
||||||
|
proto: r.proto,
|
||||||
|
srcPort: r.srcPort,
|
||||||
|
dstPort: r.dstPort,
|
||||||
|
inIface: resolved.name,
|
||||||
|
outIface: outResolved.name,
|
||||||
|
}, topo.plane)
|
||||||
|
if (!shouldKeepPlane(plane, { excludeMesh, excludeOverlay })) continue
|
||||||
|
matched.push(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
|
||||||
|
const hops = new Map<string, HopAcc>()
|
||||||
|
|
||||||
|
for (const r of working) {
|
||||||
|
const inRes = resolveIfaceName(r.serverId, r.inIface)
|
||||||
|
const outRes = resolveIfaceName(r.serverId, r.outIface)
|
||||||
|
const inName = inRes.name
|
||||||
|
const outName = outRes.name
|
||||||
|
const fromId = String(r.serverId)
|
||||||
|
const fromLabel = nameById.get(r.serverId) ?? fromId
|
||||||
|
const wanSet = topo.wanIfaces.get(r.serverId)
|
||||||
|
|
||||||
|
const inOk = ifaceUsable(inName)
|
||||||
|
const outOk = ifaceUsable(outName)
|
||||||
|
const sameIface = inOk && outOk && inName.toLowerCase() === outName.toLowerCase()
|
||||||
|
if (sameIface) {
|
||||||
|
bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "iface",
|
||||||
|
iface: inName,
|
||||||
|
}, r.bytes, "fwd")
|
||||||
|
} else {
|
||||||
|
if (inOk) {
|
||||||
|
bump(hops, `iface|${fromId}|${inName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "iface",
|
||||||
|
iface: inName,
|
||||||
|
}, r.bytes, "rev")
|
||||||
|
}
|
||||||
|
if (outOk) {
|
||||||
|
bump(hops, `iface|${fromId}|${outName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "iface",
|
||||||
|
iface: outName,
|
||||||
|
}, r.bytes, "fwd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enOut = ifaceUsable(outName) ? resolveEn(topo, r.nextHop, outName) : null
|
||||||
|
const enIn = ifaceUsable(inName) ? resolveEn(topo, "", inName) : null
|
||||||
|
const en = (enOut && enOut.id !== r.serverId ? enOut : null)
|
||||||
|
?? (enIn && enIn.id !== r.serverId ? enIn : null)
|
||||||
|
if (en) {
|
||||||
|
const toId = String(en.id)
|
||||||
|
const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev"
|
||||||
|
const greIface = dir === "fwd" && ifaceUsable(outName) ? outName : (ifaceUsable(inName) ? inName : undefined)
|
||||||
|
bump(hops, `gre|${fromId}|${toId}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId,
|
||||||
|
toLabel: en.name,
|
||||||
|
kind: "gre",
|
||||||
|
iface: greIface,
|
||||||
|
}, r.bytes, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wanSet?.size) {
|
||||||
|
if (ifaceUsable(inName) && wanSet.has(inName)) {
|
||||||
|
bump(hops, `wan|${fromId}|${inName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "wan",
|
||||||
|
iface: inName,
|
||||||
|
}, r.bytes, "rev")
|
||||||
|
}
|
||||||
|
if (ifaceUsable(outName) && wanSet.has(outName) && outName.toLowerCase() !== inName.toLowerCase()) {
|
||||||
|
bump(hops, `wan|${fromId}|${outName.toLowerCase()}`, {
|
||||||
|
fromId,
|
||||||
|
fromLabel,
|
||||||
|
toId: "",
|
||||||
|
toLabel: "",
|
||||||
|
kind: "wan",
|
||||||
|
iface: outName,
|
||||||
|
}, r.bytes, "fwd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = getFlowListenerState()
|
||||||
|
return {
|
||||||
|
hops: [...hops.values()]
|
||||||
|
.map((a) => toHop(a, windowSec))
|
||||||
|
.sort((a, b) => b.bytes - a.bytes),
|
||||||
|
live: listener.bound,
|
||||||
|
rangeMinutes: q.minutes,
|
||||||
|
windowSec,
|
||||||
|
dedupApplied: wantDedup,
|
||||||
|
excludeMeshApplied: excludeMesh,
|
||||||
|
excludeOverlayApplied: excludeOverlay,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,32 @@ async function ensureWgInputAccept(client: MikrotikClient, listenPort: number):
|
|||||||
/** Официальный авто-source UDP IPFIX, не фильтр 0.0.0.0/0. */
|
/** Официальный авто-source UDP IPFIX, не фильтр 0.0.0.0/0. */
|
||||||
export const FLOW_TARGET_SRC_AUTO = "0.0.0.0"
|
export const FLOW_TARGET_SRC_AUTO = "0.0.0.0"
|
||||||
|
|
||||||
|
async function ensureIpfixFields(client: MikrotikClient): Promise<void> {
|
||||||
|
const body = toRosBody({
|
||||||
|
bytes: "yes",
|
||||||
|
packets: "yes",
|
||||||
|
"src-address": "yes",
|
||||||
|
"dst-address": "yes",
|
||||||
|
protocol: "yes",
|
||||||
|
"src-port": "yes",
|
||||||
|
"dst-port": "yes",
|
||||||
|
"in-interface": "yes",
|
||||||
|
"out-interface": "yes",
|
||||||
|
gateway: "yes",
|
||||||
|
"first-forwarded": "yes",
|
||||||
|
"last-forwarded": "yes",
|
||||||
|
"nat-src-address": "yes",
|
||||||
|
"nat-dst-address": "yes",
|
||||||
|
})
|
||||||
|
const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/ipfix"))
|
||||||
|
const id = rows[0] ? rosRowId(rows[0]) : ""
|
||||||
|
if (id) {
|
||||||
|
await patchRosPath(client, `/ip/traffic-flow/ipfix/${encodeRosId(id)}`, body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await client.post("/ip/traffic-flow/ipfix/set", body)
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureTrafficFlow(
|
async function ensureTrafficFlow(
|
||||||
client: MikrotikClient,
|
client: MikrotikClient,
|
||||||
collectorIp: string,
|
collectorIp: string,
|
||||||
@@ -116,6 +142,12 @@ async function ensureTrafficFlow(
|
|||||||
await client.post("/ip/traffic-flow/set", body)
|
await client.post("/ip/traffic-flow/set", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureIpfixFields(client)
|
||||||
|
} catch {
|
||||||
|
/* поля IPFIX опциональны на старых ROS */
|
||||||
|
}
|
||||||
|
|
||||||
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
|
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
|
||||||
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
|
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
|
||||||
const targetBody = toRosBody({
|
const targetBody = toRosBody({
|
||||||
|
|||||||
@@ -96,10 +96,92 @@ resetFlowTemplatesForTests()
|
|||||||
parseFlowPacket(tpl, "10.255.254.3")
|
parseFlowPacket(tpl, "10.255.254.3")
|
||||||
const named = parseFlowPacket(data, "10.255.254.3")
|
const named = parseFlowPacket(data, "10.255.254.3")
|
||||||
assert.equal(named.length, 1)
|
assert.equal(named.length, 1)
|
||||||
assert.equal(named[0]?.inIface, "ether1")
|
assert.equal(named[0]?.inIface, "13")
|
||||||
assert.equal(named[0]?.src, "10.1.1.8")
|
assert.equal(named[0]?.src, "10.1.1.8")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetFlowTemplatesForTests()
|
||||||
|
{
|
||||||
|
const tpl = Buffer.alloc(16 + 20)
|
||||||
|
tpl.writeUInt16BE(10, 0)
|
||||||
|
tpl.writeUInt16BE(tpl.length, 2)
|
||||||
|
tpl.writeUInt16BE(2, 16)
|
||||||
|
tpl.writeUInt16BE(20, 18)
|
||||||
|
tpl.writeUInt16BE(256, 20)
|
||||||
|
tpl.writeUInt16BE(3, 22)
|
||||||
|
tpl.writeUInt16BE(8, 24)
|
||||||
|
tpl.writeUInt16BE(4, 26)
|
||||||
|
tpl.writeUInt16BE(12, 28)
|
||||||
|
tpl.writeUInt16BE(4, 30)
|
||||||
|
tpl.writeUInt16BE(82, 32)
|
||||||
|
tpl.writeUInt16BE(6, 34)
|
||||||
|
const data = Buffer.alloc(16 + 18)
|
||||||
|
data.writeUInt16BE(10, 0)
|
||||||
|
data.writeUInt16BE(data.length, 2)
|
||||||
|
data.writeUInt16BE(256, 16)
|
||||||
|
data.writeUInt16BE(18, 18)
|
||||||
|
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
|
||||||
|
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
|
||||||
|
data.write("ether1", 28)
|
||||||
|
parseFlowPacket(tpl, "10.255.254.4")
|
||||||
|
const namedOnly = parseFlowPacket(data, "10.255.254.4")
|
||||||
|
assert.equal(namedOnly[0]?.inIface, "ether1")
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowTemplatesForTests()
|
||||||
|
{
|
||||||
|
const fieldSpecs: Array<[number, number]> = [
|
||||||
|
[8, 4],
|
||||||
|
[12, 4],
|
||||||
|
[10, 4],
|
||||||
|
[14, 4],
|
||||||
|
[15, 4],
|
||||||
|
[152, 8],
|
||||||
|
[153, 8],
|
||||||
|
[1, 4],
|
||||||
|
]
|
||||||
|
const tplSetLen = 4 + 4 + fieldSpecs.length * 4
|
||||||
|
const tpl = Buffer.alloc(16 + tplSetLen)
|
||||||
|
tpl.writeUInt16BE(10, 0)
|
||||||
|
tpl.writeUInt16BE(tpl.length, 2)
|
||||||
|
tpl.writeUInt16BE(2, 16)
|
||||||
|
tpl.writeUInt16BE(tplSetLen, 18)
|
||||||
|
tpl.writeUInt16BE(256, 20)
|
||||||
|
tpl.writeUInt16BE(fieldSpecs.length, 22)
|
||||||
|
let off = 24
|
||||||
|
for (const [type, len] of fieldSpecs) {
|
||||||
|
tpl.writeUInt16BE(type, off)
|
||||||
|
tpl.writeUInt16BE(len, off + 2)
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
const recLen = fieldSpecs.reduce((n, [, len]) => n + len, 0)
|
||||||
|
const data = Buffer.alloc(16 + 4 + recLen)
|
||||||
|
data.writeUInt16BE(10, 0)
|
||||||
|
data.writeUInt16BE(data.length, 2)
|
||||||
|
data.writeUInt16BE(256, 16)
|
||||||
|
data.writeUInt16BE(4 + recLen, 18)
|
||||||
|
let d = 20
|
||||||
|
data[d] = 10; data[d + 1] = 100; data[d + 2] = 1; data[d + 3] = 17; d += 4
|
||||||
|
data[d] = 173; data[d + 1] = 194; data[d + 2] = 160; data[d + 3] = 163; d += 4
|
||||||
|
data.writeUInt32BE(13, d); d += 4
|
||||||
|
data.writeUInt32BE(42, d); d += 4
|
||||||
|
data[d] = 198; data[d + 1] = 51; data[d + 2] = 100; data[d + 3] = 1; d += 4
|
||||||
|
data.writeBigUInt64BE(1_700_000_000_000n, d); d += 8
|
||||||
|
data.writeBigUInt64BE(1_700_000_060_000n, d); d += 8
|
||||||
|
data.writeUInt32BE(1500, d)
|
||||||
|
parseFlowPacket(tpl, "10.255.254.5")
|
||||||
|
const extra = parseFlowPacket(data, "10.255.254.5")
|
||||||
|
assert.equal(extra.length, 1)
|
||||||
|
assert.equal(extra[0]?.src, "10.100.1.17")
|
||||||
|
assert.equal(extra[0]?.dst, "173.194.160.163")
|
||||||
|
assert.equal(extra[0]?.inIface, "13")
|
||||||
|
assert.equal(extra[0]?.outIface, "42")
|
||||||
|
assert.equal(extra[0]?.nextHop, "198.51.100.1")
|
||||||
|
assert.equal(extra[0]?.flowStartMs, 1_700_000_000_000)
|
||||||
|
assert.equal(extra[0]?.flowEndMs, 1_700_000_060_000)
|
||||||
|
assert.equal(extra[0]?.bytes, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
resetFlowTemplatesForTests()
|
resetFlowTemplatesForTests()
|
||||||
{
|
{
|
||||||
const tpl = Buffer.alloc(16 + 16 + 20)
|
const tpl = Buffer.alloc(16 + 16 + 20)
|
||||||
|
|||||||
@@ -8,6 +8,49 @@ export interface ParsedFlow {
|
|||||||
packets: number
|
packets: number
|
||||||
inIface: string
|
inIface: string
|
||||||
outIface: string
|
outIface: string
|
||||||
|
nextHop: string
|
||||||
|
flowStartMs: number
|
||||||
|
flowEndMs: number
|
||||||
|
natSrc: string
|
||||||
|
natDst: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedFlowInput = Partial<ParsedFlow> & Pick<ParsedFlow, "src" | "dst" | "proto" | "bytes">
|
||||||
|
|
||||||
|
export function emptyParsedFlow(): ParsedFlow {
|
||||||
|
return {
|
||||||
|
src: "",
|
||||||
|
dst: "",
|
||||||
|
proto: 0,
|
||||||
|
srcPort: 0,
|
||||||
|
dstPort: 0,
|
||||||
|
bytes: 0,
|
||||||
|
packets: 0,
|
||||||
|
inIface: "",
|
||||||
|
outIface: "",
|
||||||
|
nextHop: "",
|
||||||
|
flowStartMs: 0,
|
||||||
|
flowEndMs: 0,
|
||||||
|
natSrc: "",
|
||||||
|
natDst: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeParsedFlow(flow: ParsedFlowInput): ParsedFlow {
|
||||||
|
return {
|
||||||
|
...emptyParsedFlow(),
|
||||||
|
...flow,
|
||||||
|
nextHop: flow.nextHop ?? "",
|
||||||
|
flowStartMs: flow.flowStartMs ?? 0,
|
||||||
|
flowEndMs: flow.flowEndMs ?? 0,
|
||||||
|
natSrc: flow.natSrc ?? "",
|
||||||
|
natDst: flow.natDst ?? "",
|
||||||
|
inIface: flow.inIface ?? "",
|
||||||
|
outIface: flow.outIface ?? "",
|
||||||
|
srcPort: flow.srcPort ?? 0,
|
||||||
|
dstPort: flow.dstPort ?? 0,
|
||||||
|
packets: flow.packets ?? 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FieldSpec {
|
interface FieldSpec {
|
||||||
@@ -105,7 +148,7 @@ function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
|||||||
const out: ParsedFlow[] = []
|
const out: ParsedFlow[] = []
|
||||||
let off = 24
|
let off = 24
|
||||||
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
|
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
|
||||||
out.push({
|
out.push(normalizeParsedFlow({
|
||||||
src: ipv4(buf, off),
|
src: ipv4(buf, off),
|
||||||
dst: ipv4(buf, off + 4),
|
dst: ipv4(buf, off + 4),
|
||||||
packets: buf.readUInt32BE(off + 16),
|
packets: buf.readUInt32BE(off + 16),
|
||||||
@@ -115,7 +158,7 @@ function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
|||||||
proto: buf.readUInt8(off + 38),
|
proto: buf.readUInt8(off + 38),
|
||||||
inIface: String(buf.readUInt16BE(off + 12)),
|
inIface: String(buf.readUInt16BE(off + 12)),
|
||||||
outIface: String(buf.readUInt16BE(off + 14)),
|
outIface: String(buf.readUInt16BE(off + 14)),
|
||||||
})
|
}))
|
||||||
off += 48
|
off += 48
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -166,6 +209,11 @@ function recordFromFields(
|
|||||||
let inIface = ""
|
let inIface = ""
|
||||||
let outIface = ""
|
let outIface = ""
|
||||||
let ifaceName = ""
|
let ifaceName = ""
|
||||||
|
let nextHop = ""
|
||||||
|
let flowStartMs = 0
|
||||||
|
let flowEndMs = 0
|
||||||
|
let natSrc = ""
|
||||||
|
let natDst = ""
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
const field = consumeField(buf, off, f.length, limit)
|
const field = consumeField(buf, off, f.length, limit)
|
||||||
if (!field) return null
|
if (!field) return null
|
||||||
@@ -183,11 +231,26 @@ function recordFromFields(
|
|||||||
case 28:
|
case 28:
|
||||||
if (data.length === 16 && !dst) dst = ipv6(data, 0)
|
if (data.length === 16 && !dst) dst = ipv6(data, 0)
|
||||||
break
|
break
|
||||||
|
case 15:
|
||||||
|
if (data.length === 4 && !nextHop) nextHop = ipv4(data, 0)
|
||||||
|
break
|
||||||
|
case 18:
|
||||||
|
if (data.length === 4 && !nextHop) nextHop = ipv4(data, 0)
|
||||||
|
break
|
||||||
|
case 62:
|
||||||
|
if (data.length === 16 && !nextHop) nextHop = ipv6(data, 0)
|
||||||
|
break
|
||||||
case 225:
|
case 225:
|
||||||
if (data.length === 4 && !src) src = ipv4(data, 0)
|
if (data.length === 4) {
|
||||||
|
natSrc = ipv4(data, 0)
|
||||||
|
if (!src) src = natSrc
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 226:
|
case 226:
|
||||||
if (data.length === 4 && !dst) dst = ipv4(data, 0)
|
if (data.length === 4) {
|
||||||
|
natDst = ipv4(data, 0)
|
||||||
|
if (!dst) dst = natDst
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 4:
|
case 4:
|
||||||
proto = readUint(data, 0, data.length)
|
proto = readUint(data, 0, data.length)
|
||||||
@@ -216,6 +279,24 @@ function recordFromFields(
|
|||||||
case 14:
|
case 14:
|
||||||
outIface = String(readUint(data, 0, data.length))
|
outIface = String(readUint(data, 0, data.length))
|
||||||
break
|
break
|
||||||
|
case 21:
|
||||||
|
if (!flowEndMs) flowEndMs = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 22:
|
||||||
|
if (!flowStartMs) flowStartMs = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 150:
|
||||||
|
if (!flowStartMs) flowStartMs = readUint(data, 0, data.length) * 1000
|
||||||
|
break
|
||||||
|
case 151:
|
||||||
|
if (!flowEndMs) flowEndMs = readUint(data, 0, data.length) * 1000
|
||||||
|
break
|
||||||
|
case 152:
|
||||||
|
flowStartMs = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 153:
|
||||||
|
flowEndMs = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
case 82:
|
case 82:
|
||||||
ifaceName = data.toString("utf8").replace(/\0/g, "").trim()
|
ifaceName = data.toString("utf8").replace(/\0/g, "").trim()
|
||||||
break
|
break
|
||||||
@@ -224,8 +305,13 @@ function recordFromFields(
|
|||||||
}
|
}
|
||||||
off = field.next
|
off = field.next
|
||||||
}
|
}
|
||||||
if (ifaceName) inIface = ifaceName
|
if (ifaceName && !inIface) inIface = ifaceName
|
||||||
return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface }, next: off }
|
return {
|
||||||
|
flow: normalizeParsedFlow({
|
||||||
|
src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface, nextHop, flowStartMs, flowEndMs, natSrc, natDst,
|
||||||
|
}),
|
||||||
|
next: off,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDataRecords(
|
function parseDataRecords(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
classifyFlowPlane,
|
||||||
|
classifyFlowPlaneLite,
|
||||||
|
flowBps,
|
||||||
|
shouldKeepPlane,
|
||||||
|
} from "./traffic-flow-planes.js"
|
||||||
|
|
||||||
|
const youtubeInner = {
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst: "173.194.160.163",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 51234,
|
||||||
|
dstPort: 443,
|
||||||
|
inIface: "gre-client",
|
||||||
|
outIface: "NSK-SERVHOST-RTK",
|
||||||
|
}
|
||||||
|
assert.equal(classifyFlowPlaneLite(youtubeInner), "payload")
|
||||||
|
assert.equal(classifyFlowPlane(youtubeInner), "payload")
|
||||||
|
|
||||||
|
const greOverlay = {
|
||||||
|
src: "203.0.113.10",
|
||||||
|
dst: "198.51.100.1",
|
||||||
|
proto: 47,
|
||||||
|
srcPort: 0,
|
||||||
|
dstPort: 0,
|
||||||
|
inIface: "ether1",
|
||||||
|
outIface: "NSK-SERVHOST-RTK",
|
||||||
|
}
|
||||||
|
assert.equal(classifyFlowPlaneLite(greOverlay), "overlay")
|
||||||
|
|
||||||
|
const espOverlay = { ...greOverlay, proto: 50 }
|
||||||
|
assert.equal(classifyFlowPlaneLite(espOverlay), "overlay")
|
||||||
|
|
||||||
|
const mesh = {
|
||||||
|
src: "10.100.1.17",
|
||||||
|
dst: "10.100.1.18",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 50000,
|
||||||
|
dstPort: 443,
|
||||||
|
inIface: "gre-a",
|
||||||
|
outIface: "gre-b",
|
||||||
|
}
|
||||||
|
assert.equal(classifyFlowPlaneLite(mesh), "client_mesh")
|
||||||
|
|
||||||
|
const mgmt = {
|
||||||
|
src: "10.255.254.2",
|
||||||
|
dst: "10.255.254.1",
|
||||||
|
proto: 17,
|
||||||
|
srcPort: 4739,
|
||||||
|
dstPort: 4739,
|
||||||
|
inIface: "wg-flow",
|
||||||
|
outIface: "",
|
||||||
|
}
|
||||||
|
assert.equal(classifyFlowPlaneLite(mgmt), "mgmt")
|
||||||
|
assert.equal(classifyFlowPlaneLite({ ...youtubeInner, outIface: "wg-flow" }), "payload")
|
||||||
|
assert.equal(shouldKeepPlane("mgmt", {}), false)
|
||||||
|
assert.equal(shouldKeepPlane("overlay", {}), false)
|
||||||
|
assert.equal(shouldKeepPlane("client_mesh", {}), false)
|
||||||
|
assert.equal(shouldKeepPlane("payload", {}), true)
|
||||||
|
assert.equal(shouldKeepPlane("overlay", { excludeOverlay: false }), true)
|
||||||
|
assert.equal(shouldKeepPlane("client_mesh", { excludeMesh: false }), true)
|
||||||
|
|
||||||
|
assert.equal(flowBps(1500, 1_000, 2_000, 300), (1500 * 8) / 1)
|
||||||
|
assert.equal(flowBps(1500, 0, 0, 300), (1500 * 8) / 300)
|
||||||
|
|
||||||
|
const publicJhEn = {
|
||||||
|
src: "203.0.113.10",
|
||||||
|
dst: "198.51.100.1",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 1000,
|
||||||
|
dstPort: 443,
|
||||||
|
inIface: "ether1",
|
||||||
|
outIface: "gre-en",
|
||||||
|
}
|
||||||
|
assert.equal(classifyFlowPlane(publicJhEn, {
|
||||||
|
clientIfaceNames: new Set(["gre-client"]),
|
||||||
|
enHosts: new Set(["198.51.100.1"]),
|
||||||
|
jhHosts: new Set(["203.0.113.10"]),
|
||||||
|
}), "overlay")
|
||||||
|
|
||||||
|
console.log("traffic-flow-planes.test.ts: ok")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
export type FlowPlane = "payload" | "client_mesh" | "overlay" | "mgmt"
|
||||||
|
|
||||||
|
export const PLANE_LABEL: Record<FlowPlane, string> = {
|
||||||
|
payload: "Интернет",
|
||||||
|
client_mesh: "Клиенты",
|
||||||
|
overlay: "JH↔EN",
|
||||||
|
mgmt: "mgmt",
|
||||||
|
}
|
||||||
|
|
||||||
|
const WG_PORTS = new Set([51820, 13232, 51821])
|
||||||
|
const FLOW_PORTS = new Set([4739, 2055])
|
||||||
|
|
||||||
|
export function isRfc1918(ip: string): boolean {
|
||||||
|
const parts = String(ip ?? "").split(".").map((n) => Number.parseInt(n, 10))
|
||||||
|
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return false
|
||||||
|
const [a, b] = parts
|
||||||
|
if (a === 10) return true
|
||||||
|
if (a === 192 && b === 168) return true
|
||||||
|
if (a === 172 && b != null && b >= 16 && b <= 31) return true
|
||||||
|
if (a === 100 && b != null && b >= 64 && b <= 127) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPublicV4(ip: string): boolean {
|
||||||
|
const parts = String(ip ?? "").split(".").map((n) => Number.parseInt(n, 10))
|
||||||
|
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return false
|
||||||
|
const a = parts[0] ?? 0
|
||||||
|
if (a === 0 || a === 127 || a >= 224) return false
|
||||||
|
return !isRfc1918(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTunnelProto(proto: number, srcPort: number, dstPort: number): boolean {
|
||||||
|
if (proto === 47 || proto === 50) return true
|
||||||
|
if (proto === 17 && (WG_PORTS.has(srcPort) || WG_PORTS.has(dstPort))) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifaceLooksMgmt(name: string): boolean {
|
||||||
|
const n = name.trim().toLowerCase()
|
||||||
|
return n === "wg-flow" || n.endsWith("/wg-flow") || n.includes("wg-flow")
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaneFlowInput {
|
||||||
|
src: string
|
||||||
|
dst: string
|
||||||
|
proto: number
|
||||||
|
srcPort: number
|
||||||
|
dstPort: number
|
||||||
|
inIface: string
|
||||||
|
outIface?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Быстрая классификация без топологии — для live ring на ingest. */
|
||||||
|
export function classifyFlowPlaneLite(flow: PlaneFlowInput): FlowPlane {
|
||||||
|
if (ifaceLooksMgmt(flow.inIface)) return "mgmt"
|
||||||
|
if (flow.proto === 17 && (FLOW_PORTS.has(flow.srcPort) || FLOW_PORTS.has(flow.dstPort))) return "mgmt"
|
||||||
|
if (isTunnelProto(flow.proto, flow.srcPort, flow.dstPort)) return "overlay"
|
||||||
|
if (isRfc1918(flow.src) && isRfc1918(flow.dst)) return "client_mesh"
|
||||||
|
return "payload"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaneTopology {
|
||||||
|
clientIfaceNames: Set<string>
|
||||||
|
enHosts: Set<string>
|
||||||
|
jhHosts: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostHit(ip: string, hosts: Set<string>): boolean {
|
||||||
|
return Boolean(ip) && hosts.has(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyFlowPlane(
|
||||||
|
flow: PlaneFlowInput,
|
||||||
|
topo?: PlaneTopology | null,
|
||||||
|
): FlowPlane {
|
||||||
|
const lite = classifyFlowPlaneLite(flow)
|
||||||
|
if (!topo) return lite
|
||||||
|
if (lite === "mgmt") return "mgmt"
|
||||||
|
if (lite === "overlay") return "overlay"
|
||||||
|
const srcEn = hostHit(flow.src, topo.enHosts) || hostHit(flow.src, topo.jhHosts)
|
||||||
|
const dstEn = hostHit(flow.dst, topo.enHosts) || hostHit(flow.dst, topo.jhHosts)
|
||||||
|
if (srcEn && dstEn && isPublicV4(flow.src) && isPublicV4(flow.dst)) return "overlay"
|
||||||
|
if (lite === "client_mesh") {
|
||||||
|
const inClient = topo.clientIfaceNames.has(flow.inIface)
|
||||||
|
const outClient = Boolean(flow.outIface && topo.clientIfaceNames.has(flow.outIface))
|
||||||
|
if (inClient || outClient || (isRfc1918(flow.src) && isRfc1918(flow.dst))) return "client_mesh"
|
||||||
|
}
|
||||||
|
return "payload"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldKeepPlane(
|
||||||
|
plane: FlowPlane,
|
||||||
|
opts: { excludeMesh?: boolean; excludeOverlay?: boolean },
|
||||||
|
): boolean {
|
||||||
|
if (plane === "mgmt") return false
|
||||||
|
if (opts.excludeMesh !== false && plane === "client_mesh") return false
|
||||||
|
if (opts.excludeOverlay !== false && plane === "overlay") return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flowBps(bytes: number, startMs: number, endMs: number, windowSec: number): number {
|
||||||
|
if (startMs > 0 && endMs > startMs) {
|
||||||
|
const sec = Math.max(1, (endMs - startMs) / 1000)
|
||||||
|
return (bytes * 8) / sec
|
||||||
|
}
|
||||||
|
return (bytes * 8) / Math.max(1, windowSec)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), "mm-flow-purge-"))
|
||||||
|
process.env.DATABASE_PATH = path.join(dir, "test.db")
|
||||||
|
|
||||||
|
const { sqliteDatabase } = await import("../db/index.js")
|
||||||
|
const {
|
||||||
|
getFlowRuntimeCounters,
|
||||||
|
purgeTrafficFlowStore,
|
||||||
|
stopTrafficFlowListener,
|
||||||
|
} = await import("./traffic-flow-ingest.js")
|
||||||
|
|
||||||
|
function count(name: string): number {
|
||||||
|
const row = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM ${name}`).get() as { n: number }
|
||||||
|
return Number(row?.n) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO servers (name, host) VALUES ('purge-test', '127.0.0.1')
|
||||||
|
`).run()
|
||||||
|
const serverId = Number(
|
||||||
|
(sqliteDatabase.prepare(`SELECT id FROM servers WHERE name = 'purge-test'`).get() as { id: number }).id,
|
||||||
|
)
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO flow_buckets (server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface)
|
||||||
|
VALUES (?, '2026-01-01T00:00:00.000Z', '10.0.0.1', '8.8.8.8', 6, 50000, 443, 100, 1, 'wg-flow')
|
||||||
|
`).run(serverId)
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO flow_minute_stats (server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations)
|
||||||
|
VALUES (?, '2026-01-01T00:00:00.000Z', 100, 1, 1, 1, 1)
|
||||||
|
`).run(serverId)
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
|
||||||
|
VALUES (?, '2026-01-01T00:00:00.000Z', 'country', 'RU', 100, 1)
|
||||||
|
`).run(serverId)
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
|
||||||
|
VALUES (?, '2026-01-01', 'country', 'RU', 100, 1)
|
||||||
|
`).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()
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
UPDATE traffic_flow_settings SET packets_received = 42, last_exporter_ip = '10.255.254.3' WHERE id = 1
|
||||||
|
`).run()
|
||||||
|
|
||||||
|
const result = await purgeTrafficFlowStore()
|
||||||
|
stopTrafficFlowListener()
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.deleted.buckets, 1)
|
||||||
|
assert.equal(result.deleted.minuteStats, 1)
|
||||||
|
assert.equal(result.deleted.minuteDims, 1)
|
||||||
|
assert.equal(result.deleted.dailyDims, 1)
|
||||||
|
assert.equal(count("flow_buckets"), 0)
|
||||||
|
assert.equal(count("flow_minute_stats"), 0)
|
||||||
|
assert.equal(count("flow_minute_dims"), 0)
|
||||||
|
assert.equal(count("flow_daily_dims"), 0)
|
||||||
|
assert.equal(count("flow_ip_meta"), 1)
|
||||||
|
assert.equal(count("servers"), 1)
|
||||||
|
assert.equal(getFlowRuntimeCounters().packetsReceived, 0)
|
||||||
|
assert.equal(getFlowRuntimeCounters().lastExporterIp, null)
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
sqliteDatabase.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("traffic-flow-purge.test.ts: ok")
|
||||||
@@ -131,3 +131,13 @@ export function enableTrafficFlowIngest() {
|
|||||||
export function listHostPeers(): FlowHostPeer[] {
|
export function listHostPeers(): FlowHostPeer[] {
|
||||||
return parsePeers(getTrafficFlowSettingsRow().peersJson)
|
return parsePeers(getTrafficFlowSettingsRow().peersJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetFlowIngestCounters(): void {
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
packetsReceived: 0,
|
||||||
|
lastDatagramAt: null,
|
||||||
|
lastExporterIp: null,
|
||||||
|
lastError: "",
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { db, sqliteDatabase } from "../db/index.js"
|
||||||
|
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
|
||||||
|
import { mapRosInterfaceType } from "../modules/users/iface-type.js"
|
||||||
|
import type { PlaneTopology } from "./traffic-flow-planes.js"
|
||||||
|
|
||||||
|
export interface FlowClientBinding {
|
||||||
|
userId: string
|
||||||
|
login: string
|
||||||
|
name: string
|
||||||
|
serverId: number
|
||||||
|
interfaceName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowEnNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
hosts: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowTopology {
|
||||||
|
clientIfaces: Map<number, Set<string>>
|
||||||
|
clientByIface: Map<string, FlowClientBinding>
|
||||||
|
enNodes: FlowEnNode[]
|
||||||
|
enHosts: Set<string>
|
||||||
|
jhHosts: Set<string>
|
||||||
|
wanIfaces: Map<number, Set<string>>
|
||||||
|
plane: PlaneTopology
|
||||||
|
}
|
||||||
|
|
||||||
|
let seeded: FlowTopology | null = null
|
||||||
|
|
||||||
|
function parseWanUplinks(raw: string): Array<{ iface?: string; ip?: string }> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw || "[]") as unknown
|
||||||
|
return Array.isArray(parsed) ? parsed as Array<{ iface?: string; ip?: string }> : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifaceKey(serverId: number, name: string): string {
|
||||||
|
return `${serverId}|${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFlowTopology(): FlowTopology {
|
||||||
|
if (seeded) return seeded
|
||||||
|
const serverRows = db.select().from(servers).all()
|
||||||
|
const users = db.select().from(appUsers).all()
|
||||||
|
const binds = db.select().from(userInterfaceBindings).all()
|
||||||
|
const loginById = new Map(users.map((u) => [u.id, u]))
|
||||||
|
const clientIfaces = new Map<number, Set<string>>()
|
||||||
|
const clientByIface = new Map<string, FlowClientBinding>()
|
||||||
|
const allClientNames = new Set<string>()
|
||||||
|
for (const b of binds) {
|
||||||
|
const set = clientIfaces.get(b.serverId) ?? new Set<string>()
|
||||||
|
set.add(b.interfaceName)
|
||||||
|
clientIfaces.set(b.serverId, set)
|
||||||
|
allClientNames.add(b.interfaceName)
|
||||||
|
const user = loginById.get(b.userId)
|
||||||
|
clientByIface.set(ifaceKey(b.serverId, b.interfaceName), {
|
||||||
|
userId: b.userId,
|
||||||
|
login: user?.login || b.userId,
|
||||||
|
name: user?.name || user?.login || b.userId,
|
||||||
|
serverId: b.serverId,
|
||||||
|
interfaceName: b.interfaceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const enHosts = new Set<string>()
|
||||||
|
const jhHosts = new Set<string>()
|
||||||
|
const enNodes: FlowEnNode[] = []
|
||||||
|
const wanIfaces = new Map<number, Set<string>>()
|
||||||
|
for (const s of serverRows) {
|
||||||
|
const wans = parseWanUplinks(s.wanUplinks)
|
||||||
|
const hosts = [s.host, ...wans.map((w) => String(w.ip ?? "").trim())].filter(Boolean)
|
||||||
|
const wanSet = new Set(wans.map((w) => String(w.iface ?? "").trim()).filter(Boolean))
|
||||||
|
if (wanSet.size) wanIfaces.set(s.id, wanSet)
|
||||||
|
if (s.type === "exit-node") {
|
||||||
|
for (const h of hosts) enHosts.add(h)
|
||||||
|
enNodes.push({ id: s.id, name: s.name || s.host, hosts })
|
||||||
|
}
|
||||||
|
if (s.type === "jump-host") {
|
||||||
|
for (const h of hosts) jhHosts.add(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
clientIfaces,
|
||||||
|
clientByIface,
|
||||||
|
enNodes,
|
||||||
|
enHosts,
|
||||||
|
jhHosts,
|
||||||
|
wanIfaces,
|
||||||
|
plane: {
|
||||||
|
clientIfaceNames: allClientNames,
|
||||||
|
enHosts,
|
||||||
|
jhHosts,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedFlowTopologyForTests(topo: FlowTopology | null): void {
|
||||||
|
seeded = topo
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveClient(
|
||||||
|
topo: FlowTopology,
|
||||||
|
serverId: number,
|
||||||
|
inIfaceName: string,
|
||||||
|
): FlowClientBinding | null {
|
||||||
|
return topo.clientByIface.get(ifaceKey(serverId, inIfaceName)) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEn(
|
||||||
|
topo: FlowTopology,
|
||||||
|
nextHop: string,
|
||||||
|
outIfaceName: string,
|
||||||
|
): FlowEnNode | null {
|
||||||
|
if (nextHop) {
|
||||||
|
const hit = topo.enNodes.find((n) => n.hosts.includes(nextHop))
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
const needle = outIfaceName.trim().toLowerCase()
|
||||||
|
if (!needle) return null
|
||||||
|
return topo.enNodes.find((n) => {
|
||||||
|
const name = n.name.toLowerCase()
|
||||||
|
const host = (n.hosts[0] ?? "").toLowerCase()
|
||||||
|
return (name && needle.includes(name)) || (host && needle.includes(host.split(".")[0] ?? ""))
|
||||||
|
}) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enGreIfaceNames(topo: FlowTopology, serverId: number, ifaceNames: string[]): string[] {
|
||||||
|
const client = topo.clientIfaces.get(serverId) ?? new Set<string>()
|
||||||
|
return ifaceNames.filter((name) => {
|
||||||
|
if (client.has(name)) return false
|
||||||
|
if (name === "wg-flow") return false
|
||||||
|
return mapRosInterfaceType("", name) === "gre"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function latestWireBps(serverId: number, ifaceNames: string[]): { bps: number; bytes: number } {
|
||||||
|
if (!ifaceNames.length) return { bps: 0, bytes: 0 }
|
||||||
|
const placeholders = ifaceNames.map(() => "?").join(",")
|
||||||
|
const rows = sqliteDatabase.prepare(`
|
||||||
|
SELECT interface_name AS name, rx_bps AS rxBps, tx_bps AS txBps, rx_bytes AS rxBytes, tx_bytes AS txBytes
|
||||||
|
FROM traffic_samples
|
||||||
|
WHERE server_id = ? AND interface_name IN (${placeholders})
|
||||||
|
ORDER BY sampled_at DESC
|
||||||
|
`).all(serverId, ...ifaceNames) as Array<{
|
||||||
|
name: string
|
||||||
|
rxBps: number
|
||||||
|
txBps: number
|
||||||
|
rxBytes: number
|
||||||
|
txBytes: number
|
||||||
|
}>
|
||||||
|
const seen = new Set<string>()
|
||||||
|
let bps = 0
|
||||||
|
let bytes = 0
|
||||||
|
for (const r of rows) {
|
||||||
|
if (seen.has(r.name)) continue
|
||||||
|
seen.add(r.name)
|
||||||
|
bps += (Number(r.rxBps) || 0) + (Number(r.txBps) || 0)
|
||||||
|
bytes += (Number(r.rxBytes) || 0) + (Number(r.txBytes) || 0)
|
||||||
|
}
|
||||||
|
return { bps, bytes }
|
||||||
|
}
|
||||||
@@ -15,5 +15,5 @@
|
|||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,19 @@ function TrafficFlowsDataGrid({
|
|||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
|
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
|
||||||
() => [
|
() => [
|
||||||
|
{
|
||||||
|
id: "client",
|
||||||
|
accessorFn: (r) => r.clientName ?? "",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs">{row.original.clientName || "—"}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "server",
|
id: "server",
|
||||||
accessorKey: "serverName",
|
accessorKey: "serverName",
|
||||||
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
||||||
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
||||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "src",
|
id: "src",
|
||||||
@@ -96,6 +103,20 @@ function TrafficFlowsDataGrid({
|
|||||||
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "en",
|
||||||
|
accessorFn: (r) => r.enName ?? "",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">EN</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plane",
|
||||||
|
accessorFn: (r) => r.plane ?? "",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Плоскость</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.plane || "—"}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "iface",
|
id: "iface",
|
||||||
accessorKey: "inIface",
|
accessorKey: "inIface",
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||||
import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowPathRow, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||||
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, UsersIcon } from "lucide-react"
|
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, NetworkIcon, RouteIcon, ShieldIcon, UsersIcon } from "lucide-react"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid, type KpiStatItem } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
@@ -110,7 +110,7 @@ export function FlowEntityCardView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SessionFilter = {
|
type SessionFilter = {
|
||||||
kind: "application" | "category" | "service" | "asn" | "country" | "protocol" | "source" | "destination" | "iface"
|
kind: "application" | "category" | "service" | "asn" | "country" | "protocol" | "source" | "destination" | "iface" | "client" | "en"
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,8 @@ function talkerMatchesFilter(row: FlowTalkerDto, filter: SessionFilter): boolean
|
|||||||
case "source": return row.src === filter.value
|
case "source": return row.src === filter.value
|
||||||
case "destination": return row.dst === filter.value
|
case "destination": return row.dst === filter.value
|
||||||
case "iface": return row.inIface === filter.value
|
case "iface": return row.inIface === filter.value
|
||||||
|
case "client": return (row.clientId || "unknown") === filter.value
|
||||||
|
case "en": return (row.enId || "") === filter.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +212,101 @@ function FlowBreakdownGrid({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FlowPathsGrid({
|
||||||
|
rows,
|
||||||
|
empty,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
rows: FlowPathRow[]
|
||||||
|
empty?: string
|
||||||
|
onPick?: (row: FlowPathRow) => void
|
||||||
|
}) {
|
||||||
|
const columns = useMemo<ColumnDef<FlowPathRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "client",
|
||||||
|
accessorKey: "clientName",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
|
||||||
|
cell: ({ row }) => <span className="text-sm font-medium">{row.original.clientName}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ifaces",
|
||||||
|
accessorKey: "ifaces",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Ifaces</span>,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.ifaces}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jh",
|
||||||
|
accessorKey: "serverName",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs">{row.original.serverName}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "in",
|
||||||
|
accessorKey: "inIface",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">In</span>,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-xs">{row.original.inIface}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "en",
|
||||||
|
accessorKey: "enName",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">EN</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dst",
|
||||||
|
accessorKey: "dst",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Dest</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||||
|
<span className="font-mono">{row.original.dst}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rate",
|
||||||
|
accessorFn: (r) => r.bps,
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
accessorKey: "bytes",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={rows.length}
|
||||||
|
emptyMessage={empty ?? "Нет путей"}
|
||||||
|
onRowClick={onPick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function FlowAnalyticsDetail({
|
export function FlowAnalyticsDetail({
|
||||||
card,
|
card,
|
||||||
analytics,
|
analytics,
|
||||||
@@ -219,6 +316,10 @@ export function FlowAnalyticsDetail({
|
|||||||
onIface,
|
onIface,
|
||||||
dedup,
|
dedup,
|
||||||
onDedup,
|
onDedup,
|
||||||
|
excludeMesh,
|
||||||
|
onExcludeMesh,
|
||||||
|
excludeOverlay,
|
||||||
|
onExcludeOverlay,
|
||||||
liveHint,
|
liveHint,
|
||||||
emptyHint,
|
emptyHint,
|
||||||
}: {
|
}: {
|
||||||
@@ -230,6 +331,10 @@ export function FlowAnalyticsDetail({
|
|||||||
onIface: (name: string) => void
|
onIface: (name: string) => void
|
||||||
dedup: boolean
|
dedup: boolean
|
||||||
onDedup: (value: boolean) => void
|
onDedup: (value: boolean) => void
|
||||||
|
excludeMesh: boolean
|
||||||
|
onExcludeMesh: (value: boolean) => void
|
||||||
|
excludeOverlay: boolean
|
||||||
|
onExcludeOverlay: (value: boolean) => void
|
||||||
liveHint?: string
|
liveHint?: string
|
||||||
emptyHint?: string
|
emptyHint?: string
|
||||||
}) {
|
}) {
|
||||||
@@ -276,6 +381,26 @@ export function FlowAnalyticsDetail({
|
|||||||
Без дублей
|
Без дублей
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="flow-exclude-overlay"
|
||||||
|
checked={excludeOverlay}
|
||||||
|
onCheckedChange={onExcludeOverlay}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="flow-exclude-overlay" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Без overlay
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="flow-exclude-mesh"
|
||||||
|
checked={excludeMesh}
|
||||||
|
onCheckedChange={onExcludeMesh}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="flow-exclude-mesh" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Без mesh
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{RANGE_KEYS.map((r) => (
|
{RANGE_KEYS.map((r) => (
|
||||||
<button
|
<button
|
||||||
@@ -344,7 +469,7 @@ export function FlowAnalyticsDetail({
|
|||||||
<div className="mt-4 pt-4 border-t">
|
<div className="mt-4 pt-4 border-t">
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Скорость потоков"
|
aria-label="Скорость потоков"
|
||||||
items={[
|
items={([
|
||||||
{
|
{
|
||||||
id: "bps-now",
|
id: "bps-now",
|
||||||
label: "Скорость сейчас",
|
label: "Скорость сейчас",
|
||||||
@@ -355,11 +480,38 @@ export function FlowAnalyticsDetail({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bytes",
|
id: "bytes",
|
||||||
label: "Байт за период",
|
label: "Payload",
|
||||||
value: formatBytes(bytes),
|
value: formatBytes(bytes),
|
||||||
|
hint: "inner IPFIX",
|
||||||
icon: <ArrowUpIcon className="size-4" />,
|
icon: <ArrowUpIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "overlay",
|
||||||
|
label: "JH↔EN overlay",
|
||||||
|
value: fmtRate((analytics?.bpsOverlay ?? 0) / 1_000_000),
|
||||||
|
hint: analytics?.bytesOverlay ? formatBytes(analytics.bytesOverlay) : undefined,
|
||||||
|
icon: <ShieldIcon className="size-4" />,
|
||||||
|
iconClassName: "text-warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wire",
|
||||||
|
label: "Wire GRE",
|
||||||
|
value: fmtRate((analytics?.bpsWire ?? 0) / 1_000_000),
|
||||||
|
hint: "счётчик iface",
|
||||||
|
icon: <RouteIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
...(!excludeMesh
|
||||||
|
? [{
|
||||||
|
id: "mesh",
|
||||||
|
label: "Mesh",
|
||||||
|
value: formatBytes(analytics?.bytesMesh ?? 0),
|
||||||
|
hint: "клиент↔клиент",
|
||||||
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "flows",
|
id: "flows",
|
||||||
label: "Сессии",
|
label: "Сессии",
|
||||||
@@ -384,7 +536,7 @@ export function FlowAnalyticsDetail({
|
|||||||
icon: <LayersIcon className="size-4" />,
|
icon: <LayersIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
]}
|
] satisfies KpiStatItem[])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -404,6 +556,7 @@ export function FlowAnalyticsDetail({
|
|||||||
<TabsTrigger value="protocols">Протоколы</TabsTrigger>
|
<TabsTrigger value="protocols">Протоколы</TabsTrigger>
|
||||||
<TabsTrigger value="sources">Источники</TabsTrigger>
|
<TabsTrigger value="sources">Источники</TabsTrigger>
|
||||||
<TabsTrigger value="destinations">Назначения</TabsTrigger>
|
<TabsTrigger value="destinations">Назначения</TabsTrigger>
|
||||||
|
<TabsTrigger value="paths">Пути</TabsTrigger>
|
||||||
<TabsTrigger value="sessions">Сессии</TabsTrigger>
|
<TabsTrigger value="sessions">Сессии</TabsTrigger>
|
||||||
<TabsTrigger value="interfaces">Интерфейсы</TabsTrigger>
|
<TabsTrigger value="interfaces">Интерфейсы</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -440,6 +593,20 @@ export function FlowAnalyticsDetail({
|
|||||||
<TabsContent value="destinations">
|
<TabsContent value="destinations">
|
||||||
<FlowBreakdownGrid rows={analytics?.destinations ?? []} onPick={(row) => pickBreakdown("destination", row)} />
|
<FlowBreakdownGrid rows={analytics?.destinations ?? []} onPick={(row) => pickBreakdown("destination", row)} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="paths">
|
||||||
|
<FlowPathsGrid
|
||||||
|
rows={analytics?.paths ?? []}
|
||||||
|
empty="Нет путей за период"
|
||||||
|
onPick={(row) => {
|
||||||
|
setSessionFilter({
|
||||||
|
kind: "client",
|
||||||
|
value: row.clientId,
|
||||||
|
label: `${row.clientName} → ${row.enName || row.dst}`,
|
||||||
|
})
|
||||||
|
setSlice("sessions")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="sessions">
|
<TabsContent value="sessions">
|
||||||
{sessionFilter ? (
|
{sessionFilter ? (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import type { FlowPurgeDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { AlertCircleIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function formatDbFileBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} Б`
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} КБ`
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} МБ`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFlowPurgeResult(result: FlowPurgeDto): string {
|
||||||
|
const rows =
|
||||||
|
result.deleted.buckets +
|
||||||
|
result.deleted.minuteStats +
|
||||||
|
result.deleted.minuteDims +
|
||||||
|
result.deleted.dailyDims
|
||||||
|
const vacuumHint = result.vacuumed ? "" : " VACUUM не выполнен."
|
||||||
|
return `Удалено строк: ${rows}. Файл ${formatDbFileBytes(result.fileBytesBefore)} → ${formatDbFileBytes(result.fileBytesAfter)}.${vacuumHint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetflowPurgeConfirm({
|
||||||
|
open,
|
||||||
|
busy,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
busy?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>Сбросить данные NetFlow?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Будут удалены сессии и агрегаты (minute/daily) из SQLite, затем VACUUM.
|
||||||
|
Ключи WireGuard, пиры JH, настройки коллектора и кэш RIPE сохранятся.
|
||||||
|
На время операции приём IPFIX остановится.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||||
|
{busy ? "Сброс…" : "Сбросить"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
generateTrafficFlowKeys,
|
generateTrafficFlowKeys,
|
||||||
getTrafficFlowHostFiles,
|
getTrafficFlowHostFiles,
|
||||||
getTrafficFlowSettings,
|
getTrafficFlowSettings,
|
||||||
|
purgeTrafficFlowData,
|
||||||
putTrafficFlowSettings,
|
putTrafficFlowSettings,
|
||||||
} from "@/shared/api/traffic-flow"
|
} from "@/shared/api/traffic-flow"
|
||||||
|
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
|
||||||
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
|
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
|
||||||
|
|
||||||
const HOST_STEPS = [
|
const HOST_STEPS = [
|
||||||
@@ -46,6 +48,8 @@ function NetflowSettingsPanel({
|
|||||||
const [retention, setRetention] = useState("24")
|
const [retention, setRetention] = useState("24")
|
||||||
const [topN, setTopN] = useState("200")
|
const [topN, setTopN] = useState("200")
|
||||||
const [ingestOn, setIngestOn] = useState(false)
|
const [ingestOn, setIngestOn] = useState(false)
|
||||||
|
const [purgeOpen, setPurgeOpen] = useState(false)
|
||||||
|
const [purgeBusy, setPurgeBusy] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
@@ -102,6 +106,20 @@ function NetflowSettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePurgeConfirm() {
|
||||||
|
setPurgeBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await purgeTrafficFlowData(backendUrl)
|
||||||
|
toast.success(formatFlowPurgeResult(result))
|
||||||
|
setPurgeOpen(false)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
|
||||||
|
} finally {
|
||||||
|
setPurgeBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -196,18 +214,35 @@ function NetflowSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" disabled={busy} onClick={() => { void handleSave() }}>
|
<Button size="sm" disabled={busy || purgeBusy} onClick={() => { void handleSave() }}>
|
||||||
Сохранить NetFlow
|
Сохранить NetFlow
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleKeys() }}>
|
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleKeys() }}>
|
||||||
<KeyRoundIcon className="size-4" />
|
<KeyRoundIcon className="size-4" />
|
||||||
Ключи хоста
|
Ключи хоста
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleExport() }}>
|
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleExport() }}>
|
||||||
<DownloadIcon className="size-4" />
|
<DownloadIcon className="size-4" />
|
||||||
wg-quick / compose / firewall
|
wg-quick / compose / firewall
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-md border border-destructive/30 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">Сбросить данные NetFlow</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Удалит сессии и агрегаты из SQLite, затем VACUUM. Ключи WG и пиры не трогает.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={busy || purgeBusy}
|
||||||
|
onClick={() => setPurgeOpen(true)}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
<CodeExportSheet
|
<CodeExportSheet
|
||||||
@@ -217,6 +252,13 @@ function NetflowSettingsPanel({
|
|||||||
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
||||||
formats={formats}
|
formats={formats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<NetflowPurgeConfirm
|
||||||
|
open={purgeOpen}
|
||||||
|
busy={purgeBusy}
|
||||||
|
onConfirm={() => { void handlePurgeConfirm() }}
|
||||||
|
onCancel={() => { if (!purgeBusy) setPurgeOpen(false) }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function useFlowLive(opts: {
|
|||||||
userId?: string
|
userId?: string
|
||||||
iface?: string
|
iface?: string
|
||||||
dedup?: boolean
|
dedup?: boolean
|
||||||
|
excludeMesh?: boolean
|
||||||
|
excludeOverlay?: boolean
|
||||||
}): { sample: FlowAnalyticsDto | null; error: string | null } {
|
}): { sample: FlowAnalyticsDto | null; error: string | null } {
|
||||||
const [sample, setSample] = useState<FlowAnalyticsDto | null>(null)
|
const [sample, setSample] = useState<FlowAnalyticsDto | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -43,6 +45,8 @@ export function useFlowLive(opts: {
|
|||||||
userId: opts.userId,
|
userId: opts.userId,
|
||||||
iface: opts.iface,
|
iface: opts.iface,
|
||||||
dedup: opts.dedup,
|
dedup: opts.dedup,
|
||||||
|
excludeMesh: opts.excludeMesh,
|
||||||
|
excludeOverlay: opts.excludeOverlay,
|
||||||
})}`
|
})}`
|
||||||
const url = resolveApiUrl(opts.backendUrl, path)
|
const url = resolveApiUrl(opts.backendUrl, path)
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ export function useFlowLive(opts: {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return () => ac.abort()
|
return () => ac.abort()
|
||||||
}, [opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup])
|
}, [opts.enabled, opts.backendUrl, opts.range, opts.serverId, opts.userId, opts.iface, opts.dedup, opts.excludeMesh, opts.excludeOverlay])
|
||||||
|
|
||||||
return { sample, error }
|
return { sample, error }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { FlowMapHop } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { fmtRate } from "@/lib/fmt-rate"
|
||||||
|
|
||||||
|
export interface MatchedNetflowHop {
|
||||||
|
bps: number
|
||||||
|
bpsFwd: number
|
||||||
|
bpsRev: number
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifaceNorm(s: string | undefined): string {
|
||||||
|
return (s ?? "").trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairKey(a: string, b: string): string {
|
||||||
|
const x = String(a)
|
||||||
|
const y = String(b)
|
||||||
|
return x <= y ? `${x}\t${y}` : `${y}\t${x}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDirected(hops: FlowMapHop[], mapFromId: string): MatchedNetflowHop {
|
||||||
|
let bytes = 0
|
||||||
|
let bpsFwd = 0
|
||||||
|
let bpsRev = 0
|
||||||
|
const from = String(mapFromId)
|
||||||
|
for (const h of hops) {
|
||||||
|
bytes += h.bytes
|
||||||
|
if (h.fromId === from) {
|
||||||
|
bpsFwd += h.bpsFwd
|
||||||
|
bpsRev += h.bpsRev
|
||||||
|
} else {
|
||||||
|
bpsFwd += h.bpsRev
|
||||||
|
bpsRev += h.bpsFwd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bytes, bpsFwd, bpsRev, bps: bpsFwd + bpsRev }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hopHasRate(h: MatchedNetflowHop | undefined): h is MatchedNetflowHop {
|
||||||
|
return h != null && Number.isFinite(h.bps) && h.bps > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNetflowRate(hop: MatchedNetflowHop): string {
|
||||||
|
return fmtRate(hop.bps / 1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNetflowDir(hop: MatchedNetflowHop): string {
|
||||||
|
return `↓${fmtRate(hop.bpsFwd / 1_000_000)} ↑${fmtRate(hop.bpsRev / 1_000_000)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GRE: сначала имя интерфейса туннеля на любом конце, иначе пара узлов. */
|
||||||
|
export function matchNetflowForGreEdge(
|
||||||
|
edge: {
|
||||||
|
tunnel: { name: string }
|
||||||
|
fromServer: { id: string }
|
||||||
|
toServer: { id: string }
|
||||||
|
},
|
||||||
|
hops: FlowMapHop[],
|
||||||
|
): MatchedNetflowHop | undefined {
|
||||||
|
const name = ifaceNorm(edge.tunnel.name)
|
||||||
|
const fromId = String(edge.fromServer.id)
|
||||||
|
const toId = String(edge.toServer.id)
|
||||||
|
if (name) {
|
||||||
|
const ifaceHits = hops.filter((h) =>
|
||||||
|
h.kind === "iface"
|
||||||
|
&& ifaceNorm(h.iface) === name
|
||||||
|
&& (h.fromId === fromId || h.fromId === toId),
|
||||||
|
)
|
||||||
|
if (ifaceHits.length) return mergeDirected(ifaceHits, fromId)
|
||||||
|
const greNamed = hops.filter((h) =>
|
||||||
|
h.kind === "gre"
|
||||||
|
&& ifaceNorm(h.iface) === name
|
||||||
|
&& (h.fromId === fromId || h.fromId === toId || h.toId === fromId || h.toId === toId),
|
||||||
|
)
|
||||||
|
if (greNamed.length) return mergeDirected(greNamed, fromId)
|
||||||
|
}
|
||||||
|
const want = pairKey(fromId, toId)
|
||||||
|
const pairHits = hops.filter((h) =>
|
||||||
|
h.kind === "gre" && Boolean(h.toId) && pairKey(h.fromId, h.toId) === want,
|
||||||
|
)
|
||||||
|
if (pairHits.length) return mergeDirected(pairHits, fromId)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WAN-аплинк HR: kind wan, иначе iface с тем же именем на homeId. */
|
||||||
|
export function matchNetflowForWan(
|
||||||
|
homeId: string,
|
||||||
|
wanIface: string,
|
||||||
|
hops: FlowMapHop[],
|
||||||
|
): MatchedNetflowHop | undefined {
|
||||||
|
const id = String(homeId)
|
||||||
|
const iface = ifaceNorm(wanIface)
|
||||||
|
if (!iface) return undefined
|
||||||
|
const wanHits = hops.filter((h) =>
|
||||||
|
h.kind === "wan" && h.fromId === id && ifaceNorm(h.iface) === iface,
|
||||||
|
)
|
||||||
|
if (wanHits.length) return mergeDirected(wanHits, id)
|
||||||
|
const ifaceHits = hops.filter((h) =>
|
||||||
|
h.kind === "iface" && h.fromId === id && ifaceNorm(h.iface) === iface,
|
||||||
|
)
|
||||||
|
if (ifaceHits.length) return mergeDirected(ifaceHits, id)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -80,11 +80,18 @@ export const flowTalkerDtoSchema = z.object({
|
|||||||
bps: z.number().nonnegative(),
|
bps: z.number().nonnegative(),
|
||||||
inIface: z.string(),
|
inIface: z.string(),
|
||||||
inIfaceIndex: z.string().optional(),
|
inIfaceIndex: z.string().optional(),
|
||||||
|
outIface: z.string().optional(),
|
||||||
|
nextHop: z.string().optional(),
|
||||||
application: z.string().optional(),
|
application: z.string().optional(),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
service: z.string().optional(),
|
service: z.string().optional(),
|
||||||
dstCountry: z.string().optional(),
|
dstCountry: z.string().optional(),
|
||||||
dstAsn: z.number().int().optional(),
|
dstAsn: z.number().int().optional(),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
clientName: z.string().optional(),
|
||||||
|
enId: z.string().optional(),
|
||||||
|
enName: z.string().optional(),
|
||||||
|
plane: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const flowStatsDtoSchema = z.object({
|
export const flowStatsDtoSchema = z.object({
|
||||||
@@ -148,6 +155,26 @@ export const flowMapEdgeSchema = z.object({
|
|||||||
bps: z.number().nonnegative(),
|
bps: z.number().nonnegative(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const flowPathRowSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
clientId: z.string(),
|
||||||
|
clientName: z.string(),
|
||||||
|
ifaces: z.string(),
|
||||||
|
serverId: z.string(),
|
||||||
|
serverName: z.string(),
|
||||||
|
inIface: z.string(),
|
||||||
|
outIface: z.string(),
|
||||||
|
enId: z.string(),
|
||||||
|
enName: z.string(),
|
||||||
|
dst: z.string(),
|
||||||
|
service: z.string(),
|
||||||
|
category: z.string(),
|
||||||
|
plane: z.string(),
|
||||||
|
bytes: z.number().nonnegative(),
|
||||||
|
packets: z.number().nonnegative(),
|
||||||
|
bps: z.number().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
export const flowAnalyticsDtoSchema = z.object({
|
export const flowAnalyticsDtoSchema = z.object({
|
||||||
bpsNow: z.number().nonnegative(),
|
bpsNow: z.number().nonnegative(),
|
||||||
bytes: z.number().nonnegative(),
|
bytes: z.number().nonnegative(),
|
||||||
@@ -171,10 +198,19 @@ export const flowAnalyticsDtoSchema = z.object({
|
|||||||
services: z.array(flowBreakdownRowSchema).optional(),
|
services: z.array(flowBreakdownRowSchema).optional(),
|
||||||
mapEdges: z.array(flowMapEdgeSchema).optional(),
|
mapEdges: z.array(flowMapEdgeSchema).optional(),
|
||||||
conversationsList: z.array(flowTalkerDtoSchema),
|
conversationsList: z.array(flowTalkerDtoSchema),
|
||||||
|
paths: z.array(flowPathRowSchema).optional(),
|
||||||
ifaces: z.array(flowIfaceChipSchema),
|
ifaces: z.array(flowIfaceChipSchema),
|
||||||
live: z.boolean(),
|
live: z.boolean(),
|
||||||
dedupApplied: z.boolean().optional(),
|
dedupApplied: z.boolean().optional(),
|
||||||
degraded: z.boolean().optional(),
|
degraded: z.boolean().optional(),
|
||||||
|
bytesPayload: z.number().nonnegative().optional(),
|
||||||
|
bytesOverlay: z.number().nonnegative().optional(),
|
||||||
|
bytesMesh: z.number().nonnegative().optional(),
|
||||||
|
bytesWire: z.number().nonnegative().optional(),
|
||||||
|
bpsOverlay: z.number().nonnegative().optional(),
|
||||||
|
bpsWire: z.number().nonnegative().optional(),
|
||||||
|
excludeMeshApplied: z.boolean().optional(),
|
||||||
|
excludeOverlayApplied: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const flowExportersDtoSchema = z.object({
|
export const flowExportersDtoSchema = z.object({
|
||||||
@@ -199,13 +235,56 @@ export const flowMonthlyDtoSchema = z.object({
|
|||||||
asns: z.array(flowBreakdownRowSchema),
|
asns: z.array(flowBreakdownRowSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const flowPurgeDtoSchema = z.object({
|
||||||
|
ok: z.literal(true),
|
||||||
|
deleted: z.object({
|
||||||
|
buckets: z.number().int().nonnegative(),
|
||||||
|
minuteStats: z.number().int().nonnegative(),
|
||||||
|
minuteDims: z.number().int().nonnegative(),
|
||||||
|
dailyDims: z.number().int().nonnegative(),
|
||||||
|
}),
|
||||||
|
fileBytesBefore: z.number().int().nonnegative(),
|
||||||
|
fileBytesAfter: z.number().int().nonnegative(),
|
||||||
|
vacuumed: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowMapHopKindSchema = z.enum(["gre", "wan", "iface"])
|
||||||
|
|
||||||
|
export const flowMapHopDtoSchema = z.object({
|
||||||
|
fromId: z.string(),
|
||||||
|
fromLabel: z.string(),
|
||||||
|
toId: z.string(),
|
||||||
|
toLabel: z.string(),
|
||||||
|
kind: flowMapHopKindSchema,
|
||||||
|
iface: z.string().optional(),
|
||||||
|
bytes: z.number().nonnegative(),
|
||||||
|
bps: z.number().nonnegative(),
|
||||||
|
bpsFwd: z.number().nonnegative(),
|
||||||
|
bpsRev: z.number().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowMapHopsDtoSchema = z.object({
|
||||||
|
hops: z.array(flowMapHopDtoSchema),
|
||||||
|
live: z.boolean(),
|
||||||
|
rangeMinutes: z.number().int().positive(),
|
||||||
|
windowSec: z.number().positive(),
|
||||||
|
dedupApplied: z.boolean(),
|
||||||
|
excludeMeshApplied: z.boolean(),
|
||||||
|
excludeOverlayApplied: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
|
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
|
||||||
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
|
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
|
||||||
export type FlowBreakdownRow = z.infer<typeof flowBreakdownRowSchema>
|
export type FlowBreakdownRow = z.infer<typeof flowBreakdownRowSchema>
|
||||||
export type FlowIfaceChip = z.infer<typeof flowIfaceChipSchema>
|
export type FlowIfaceChip = z.infer<typeof flowIfaceChipSchema>
|
||||||
export type FlowEntityCard = z.infer<typeof flowEntityCardSchema>
|
export type FlowEntityCard = z.infer<typeof flowEntityCardSchema>
|
||||||
export type FlowMapEdge = z.infer<typeof flowMapEdgeSchema>
|
export type FlowMapEdge = z.infer<typeof flowMapEdgeSchema>
|
||||||
|
export type FlowPathRow = z.infer<typeof flowPathRowSchema>
|
||||||
export type FlowAnalyticsDto = z.infer<typeof flowAnalyticsDtoSchema>
|
export type FlowAnalyticsDto = z.infer<typeof flowAnalyticsDtoSchema>
|
||||||
export type FlowExportersDto = z.infer<typeof flowExportersDtoSchema>
|
export type FlowExportersDto = z.infer<typeof flowExportersDtoSchema>
|
||||||
export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema>
|
export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema>
|
||||||
export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
|
export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
|
||||||
|
export type FlowPurgeDto = z.infer<typeof flowPurgeDtoSchema>
|
||||||
|
export type FlowMapHopKind = z.infer<typeof flowMapHopKindSchema>
|
||||||
|
export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
|
||||||
|
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import type {
|
|||||||
FlowAnalyticsDto,
|
FlowAnalyticsDto,
|
||||||
FlowClientsDto,
|
FlowClientsDto,
|
||||||
FlowExportersDto,
|
FlowExportersDto,
|
||||||
|
FlowMapHopsDto,
|
||||||
FlowMonthlyDto,
|
FlowMonthlyDto,
|
||||||
|
FlowPurgeDto,
|
||||||
FlowStatsDto,
|
FlowStatsDto,
|
||||||
TrafficFlowHostFile,
|
TrafficFlowHostFile,
|
||||||
TrafficFlowOverlayResult,
|
TrafficFlowOverlayResult,
|
||||||
@@ -61,6 +63,8 @@ function flowQuery(params: {
|
|||||||
userId?: string
|
userId?: string
|
||||||
iface?: string
|
iface?: string
|
||||||
dedup?: boolean
|
dedup?: boolean
|
||||||
|
excludeMesh?: boolean
|
||||||
|
excludeOverlay?: boolean
|
||||||
}): string {
|
}): string {
|
||||||
const q = new URLSearchParams()
|
const q = new URLSearchParams()
|
||||||
if (params.range) q.set("range", params.range)
|
if (params.range) q.set("range", params.range)
|
||||||
@@ -69,6 +73,10 @@ function flowQuery(params: {
|
|||||||
if (params.iface && params.iface !== "__all__") q.set("iface", params.iface)
|
if (params.iface && params.iface !== "__all__") q.set("iface", params.iface)
|
||||||
if (params.dedup === false) q.set("dedup", "0")
|
if (params.dedup === false) q.set("dedup", "0")
|
||||||
else if (params.dedup === true) q.set("dedup", "1")
|
else if (params.dedup === true) q.set("dedup", "1")
|
||||||
|
if (params.excludeMesh === false) q.set("excludeMesh", "0")
|
||||||
|
else if (params.excludeMesh === true) q.set("excludeMesh", "1")
|
||||||
|
if (params.excludeOverlay === false) q.set("excludeOverlay", "0")
|
||||||
|
else if (params.excludeOverlay === true) q.set("excludeOverlay", "1")
|
||||||
const s = q.toString()
|
const s = q.toString()
|
||||||
return s ? `?${s}` : ""
|
return s ? `?${s}` : ""
|
||||||
}
|
}
|
||||||
@@ -81,9 +89,40 @@ export async function getFlowClients(baseUrl: string, range = "5m"): Promise<Flo
|
|||||||
return requestJson<FlowClientsDto>(baseUrl, `/api/traffic/flow/clients?range=${encodeURIComponent(range)}`)
|
return requestJson<FlowClientsDto>(baseUrl, `/api/traffic/flow/clients?range=${encodeURIComponent(range)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFlowMapHops(
|
||||||
|
baseUrl: string,
|
||||||
|
params: {
|
||||||
|
range?: string
|
||||||
|
serverId?: string
|
||||||
|
userId?: string
|
||||||
|
iface?: string
|
||||||
|
dedup?: boolean
|
||||||
|
excludeMesh?: boolean
|
||||||
|
excludeOverlay?: boolean
|
||||||
|
} = {},
|
||||||
|
): Promise<FlowMapHopsDto> {
|
||||||
|
return requestJson<FlowMapHopsDto>(baseUrl, `/api/traffic/flow/map-hops${flowQuery({
|
||||||
|
range: params.range ?? "5m",
|
||||||
|
serverId: params.serverId,
|
||||||
|
userId: params.userId,
|
||||||
|
iface: params.iface,
|
||||||
|
dedup: params.dedup,
|
||||||
|
excludeMesh: params.excludeMesh,
|
||||||
|
excludeOverlay: params.excludeOverlay,
|
||||||
|
})}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getFlowAnalytics(
|
export async function getFlowAnalytics(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
params: { range?: string; serverId?: string; userId?: string; iface?: string; dedup?: boolean },
|
params: {
|
||||||
|
range?: string
|
||||||
|
serverId?: string
|
||||||
|
userId?: string
|
||||||
|
iface?: string
|
||||||
|
dedup?: boolean
|
||||||
|
excludeMesh?: boolean
|
||||||
|
excludeOverlay?: boolean
|
||||||
|
},
|
||||||
): Promise<FlowAnalyticsDto> {
|
): Promise<FlowAnalyticsDto> {
|
||||||
return requestJson<FlowAnalyticsDto>(baseUrl, `/api/traffic/flow/analytics${flowQuery(params)}`)
|
return requestJson<FlowAnalyticsDto>(baseUrl, `/api/traffic/flow/analytics${flowQuery(params)}`)
|
||||||
}
|
}
|
||||||
@@ -98,4 +137,8 @@ export async function getFlowMonthly(
|
|||||||
return requestJson<FlowMonthlyDto>(baseUrl, `/api/traffic/flow/monthly?${q.toString()}`)
|
return requestJson<FlowMonthlyDto>(baseUrl, `/api/traffic/flow/monthly?${q.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function purgeTrafficFlowData(baseUrl: string): Promise<FlowPurgeDto> {
|
||||||
|
return requestJson<FlowPurgeDto>(baseUrl, "/api/traffic/flow/purge", { method: "POST" })
|
||||||
|
}
|
||||||
|
|
||||||
export { flowQuery }
|
export { flowQuery }
|
||||||
|
|||||||
Reference in New Issue
Block a user