Compare commits

..
3 Commits
Author SHA1 Message Date
DenozordecandCursor 77425cca32 fix(network-map): якорить сервисы на выходную ноду
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m46s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 16s
Пунктир от EN, а не от JH; перетаскивание узлов сервисов; иконка Google без foreignObject.

Co-authored-by: Cursor <[email protected]>
2026-09-07 16:10:39 +07:00
DenozordecandCursor 29d245cde3 feat(traffic): enhance flow analytics and brand classification
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-image (push) Successful in 2m12s
Docker images / frontend-image (push) Successful in 4m3s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 12s
- Introduced `pickInternetPeer` function to improve IP classification by selecting the appropriate public IP from source and destination.
- Updated `buildFlowAnalytics` and related functions to utilize the new peer selection logic, enhancing accuracy in flow analytics.
- Added tests for new classifications and ensured existing tests cover new scenarios for Google and Cloudflare.
- Refactored traffic flow brand mappings to include additional CIDR ranges for Google and Cloudflare.

Co-authored-by: Cursor <[email protected]>
2026-09-07 15:35:52 +07:00
DenozordecandCursor 7a491a325d fix(network-map): убрать тяжёлую классификацию из poll карты
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m53s
Docker images / frontend-image (push) Successful in 3m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 12s
Не сканировать RIPE и каталог по каждой строке окна; сервисы по уникальным dst и кэшу ASN. Порог доли на карте настраиваемый и отключаемый.

Co-authored-by: Cursor <[email protected]>
2026-09-07 14:17:04 +07:00
22 changed files with 682 additions and 103 deletions
+78 -24
View File
@@ -282,11 +282,11 @@ const MOCK_MAP_SERVICES: FlowMapService[] = [
] ]
const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [ const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv2", toId: "svc:google", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000 }, { fromId: "srv2", toId: "svc:google", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000 }, { fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv2", toId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000 }, { fromId: "srv2", toId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000 }, { fromId: "srv3", toId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000 }, { fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
] ]
function serviceSharePct(share: number): string { function serviceSharePct(share: number): string {
@@ -637,7 +637,9 @@ function ServiceNode({
y, y,
isSel, isSel,
isVis, isVis,
isDragged,
onClick, onClick,
onMouseDown,
}: { }: {
label: string label: string
share: number share: number
@@ -645,15 +647,18 @@ function ServiceNode({
y: number y: number
isSel: boolean isSel: boolean
isVis: boolean isVis: boolean
isDragged: boolean
onClick: () => void onClick: () => void
onMouseDown: (e: React.MouseEvent) => void
}) { }) {
const bw = 86 const bw = 86
const bh = 58 const bh = 58
return ( return (
<g <g
transform={`translate(${x},${y})`} transform={`translate(${x},${y})`}
style={{ cursor: "pointer", transition: "opacity 0.25s" }} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
opacity={isVis ? 1 : 0.08} opacity={isVis ? 1 : 0.08}
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
onClick={(e) => { e.stopPropagation(); onClick() }} onClick={(e) => { e.stopPropagation(); onClick() }}
> >
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title> <title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
@@ -680,14 +685,9 @@ function ServiceNode({
stroke="#22d3ee" stroke="#22d3ee"
strokeWidth={isSel ? 2.2 : 1.4} strokeWidth={isSel ? 2.2 : 1.4}
/> />
<foreignObject x={-14} y={-24} width={28} height={28} style={{ overflow: "visible", pointerEvents: "none" }}> <g transform="translate(-11,-24)" pointerEvents="none">
<div <ServiceBrandIcon label={label} size={22} />
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28 }} </g>
{...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}
>
<ServiceBrandIcon label={label} size={22} />
</div>
</foreignObject>
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace"> <text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
{label} {label}
</text> </text>
@@ -938,6 +938,7 @@ export default function NetworkMapPage() {
const [mapHops, setMapHops] = useState<FlowMapHop[]>([]) const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
const [mapServices, setMapServices] = useState<FlowMapService[]>([]) const [mapServices, setMapServices] = useState<FlowMapService[]>([])
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([]) const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
const [mapSharePct, setMapSharePct] = useState(5)
/** 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)
@@ -1032,6 +1033,7 @@ export default function NetworkMapPage() {
setMapHops([]) setMapHops([])
setMapServices(MOCK_MAP_SERVICES) setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapSharePct(5)
setDataError(null) setDataError(null)
}) })
return return
@@ -1066,6 +1068,7 @@ export default function NetworkMapPage() {
// ── Node positions (overrides POS defaults) ───────────────────────────────── // ── Node positions (overrides POS defaults) ─────────────────────────────────
const [nodePositions, setNodePositions] = useState<Record<string, { x: number; y: number }>>({}) const [nodePositions, setNodePositions] = useState<Record<string, { x: number; y: number }>>({})
const [satPositions, setSatPositions] = useState<Record<string, { x: number; y: number }[]>>({}) const [satPositions, setSatPositions] = useState<Record<string, { x: number; y: number }[]>>({})
const [servicePositions, setServicePositions] = useState<Record<string, { x: number; y: number }>>({})
/** После обновления алгоритма раскладки (см. NETWORK_MAP_LAYOUT_REVISION) сбрасываем drag, иначе старые координаты «перебивают» computeNetworkMapLayout. */ /** После обновления алгоритма раскладки (см. NETWORK_MAP_LAYOUT_REVISION) сбрасываем drag, иначе старые координаты «перебивают» computeNetworkMapLayout. */
useEffect(() => { useEffect(() => {
@@ -1077,6 +1080,7 @@ export default function NetworkMapPage() {
// и перекрывают новый авто-лейаут на live-данных. // и перекрывают новый авто-лейаут на live-данных.
setNodePositions({}) setNodePositions({})
setSatPositions({}) setSatPositions({})
setServicePositions({})
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION)) sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
} }
} catch { } catch {
@@ -1106,6 +1110,7 @@ export default function NetworkMapPage() {
setMapHops([]) setMapHops([])
setMapServices(MOCK_MAP_SERVICES) setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapSharePct(5)
}) })
return return
} }
@@ -1118,25 +1123,29 @@ export default function NetworkMapPage() {
return return
} }
let cancelled = false let cancelled = false
let ac: AbortController | null = null
const tick = () => { const tick = () => {
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m") ac?.abort()
ac = new AbortController()
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal })
.then((res) => { .then((res) => {
if (cancelled) return if (cancelled) return
setMapHops(res.hops ?? []) setMapHops(res.hops ?? [])
setMapServices(res.services ?? []) setMapServices(res.services ?? [])
setMapServiceEdges(res.serviceEdges ?? []) setMapServiceEdges(res.serviceEdges ?? [])
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
}) })
.catch(() => { .catch((err: unknown) => {
if (cancelled) return if (cancelled) return
setMapHops([]) const name = err instanceof Error ? err.name : ""
setMapServices([]) if (name === "AbortError") return
setMapServiceEdges([])
}) })
} }
tick() tick()
const id = window.setInterval(tick, 4000) const id = window.setInterval(tick, 4000)
return () => { return () => {
cancelled = true cancelled = true
ac?.abort()
window.clearInterval(id) window.clearInterval(id)
} }
}, [useLiveData, showNetflow, showServices, apiFetch]) }, [useLiveData, showNetflow, showServices, apiFetch])
@@ -1344,13 +1353,16 @@ export default function NetworkMapPage() {
}) })
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n])) const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
const servicePosById = placeServiceNodes( const autoServicePos = placeServiceNodes(
visibleMapServices.map((s) => s.id), visibleMapServices.map((s) => s.id),
mapServers mapServers
.filter((s) => s.type === "exit-node") .filter((s) => s.type === "exit-node")
.map((s) => nodePosById[s.id]) .map((s) => nodePosById[s.id])
.filter((p): p is { x: number; y: number } => Boolean(p)), .filter((p): p is { x: number; y: number } => Boolean(p)),
) )
const servicePosById = Object.fromEntries(
visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoServicePos[s.id]!]),
)
// ── Refs ───────────────────────────────────────────────────────────────────── // ── Refs ─────────────────────────────────────────────────────────────────────
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
@@ -1366,10 +1378,12 @@ export default function NetworkMapPage() {
type NodeDrag = type NodeDrag =
| { kind: "server"; nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean } | { kind: "server"; nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
| { kind: "wan-sat"; homeId: string; wanIdx: number; startX: number; startY: number; origX: number; origY: number; moved: boolean } | { kind: "wan-sat"; homeId: string; wanIdx: number; startX: number; startY: number; origX: number; origY: number; moved: boolean }
| { kind: "service"; svcId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
const nodeDragRef = useRef<NodeDrag | null>(null) const nodeDragRef = useRef<NodeDrag | null>(null)
const suppressClickRef = useRef(false) const suppressClickRef = useRef(false)
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null) const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
const [draggedSatKey, setDraggedSatKey] = useState<string | null>(null) // `${homeId}-${wanIdx}` const [draggedSatKey, setDraggedSatKey] = useState<string | null>(null) // `${homeId}-${wanIdx}`
const [draggedSvcId, setDraggedSvcId] = useState<string | null>(null)
const zoomRef = useRef(zoom) const zoomRef = useRef(zoom)
const panRef = useRef(pan) const panRef = useRef(pan)
@@ -1479,6 +1493,8 @@ export default function NetworkMapPage() {
if (nd.kind === "server") { if (nd.kind === "server") {
setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } })) setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } }))
} else if (nd.kind === "service") {
setServicePositions(prev => ({ ...prev, [nd.svcId]: { x: nx, y: ny } }))
} else { } else {
setSatPositions(prev => { setSatPositions(prev => {
const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))] const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))]
@@ -1506,6 +1522,7 @@ export default function NetworkMapPage() {
nodeDragRef.current = null nodeDragRef.current = null
setDraggedNodeId(null) setDraggedNodeId(null)
setDraggedSatKey(null) setDraggedSatKey(null)
setDraggedSvcId(null)
return true return true
} }
@@ -1526,6 +1543,10 @@ export default function NetworkMapPage() {
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false } nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
setDraggedSatKey(`${homeId}-${wanIdx}`) setDraggedSatKey(`${homeId}-${wanIdx}`)
} }
function onServiceMouseDown(e: React.MouseEvent, svcId: string, x: number, y: number) {
nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
setDraggedSvcId(svcId)
}
// ── Visibility / search ────────────────────────────────────────────────── // ── Visibility / search ──────────────────────────────────────────────────
const sq = search.toLowerCase().trim() const sq = search.toLowerCase().trim()
@@ -1736,10 +1757,15 @@ export default function NetworkMapPage() {
</span> </span>
</button> </button>
))} ))}
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0) && ( <p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
{mapSharePct > 0
? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
</p>
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && (
<div className="border-t border-border/50 mt-1 pt-1"> <div className="border-t border-border/50 mt-1 pt-1">
<button <button
onClick={() => { setNodePositions({}); setSatPositions({}) }} onClick={() => { setNodePositions({}); setSatPositions({}); setServicePositions({}) }}
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs className="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs
text-amber-400 hover:bg-amber-500/10 transition-colors"> text-amber-400 hover:bg-amber-500/10 transition-colors">
Сбросить расположение Сбросить расположение
@@ -1993,7 +2019,7 @@ export default function NetworkMapPage() {
) )
})} })}
{/* ── EN/JH → destination services ── */} {/* ── EN → destination services ── */}
{visibleServiceEdges.map((edge) => { {visibleServiceEdges.map((edge) => {
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId] const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
const to = servicePosById[edge.toId] const to = servicePosById[edge.toId]
@@ -2006,8 +2032,13 @@ export default function NetworkMapPage() {
} }
const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16) const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16)
const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId
const svc = visibleMapServices.find((s) => s.id === edge.toId)
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel}${enName}${svc?.label ?? edge.toId}`
return ( return (
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}> <g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
<title>{pathTitle}</title>
<line <line
x1={from.x} y1={from.y} x2={to.x} y2={to.y} x1={from.x} y1={from.y} x2={to.x} y2={to.y}
stroke="#22d3ee" stroke="#22d3ee"
@@ -2095,6 +2126,8 @@ export default function NetworkMapPage() {
y={pos.y} y={pos.y}
isSel={selectedService?.id === svc.id} isSel={selectedService?.id === svc.id}
isVis isVis
isDragged={draggedSvcId === svc.id}
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => { onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return } if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectService(svc) selectService(svc)
@@ -2479,7 +2512,7 @@ export default function NetworkMapPage() {
</div> </div>
</div> </div>
<div> <div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">С узлов</p> <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => { {visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
const src = mapServers.find((s) => s.id === e.fromId) const src = mapServers.find((s) => s.id === e.fromId)
@@ -2494,6 +2527,27 @@ export default function NetworkMapPage() {
})} })}
</div> </div>
</div> </div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Клиенты</p>
<div className="flex flex-col gap-1.5">
{(() => {
const names = new Map<string, string>()
for (const e of visibleServiceEdges.filter((x) => x.toId === selectedService.id)) {
if (e.clients?.length) {
for (const c of e.clients) names.set(c.id, c.name)
} else if (e.clientName) {
names.set(e.clientId ?? e.clientName, e.clientName)
}
}
if (names.size === 0) {
return <p className="text-xs text-muted-foreground"></p>
}
return [...names.values()].map((name) => (
<p key={name} className="text-xs font-mono truncate">{name}</p>
))
})()}
</div>
</div>
</div> </div>
</> </>
) : selected ? ( ) : selected ? (
+1 -1
View File
@@ -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-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:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-hardening.test.ts && tsx src/services/traffic-flow-purge.test.ts",
"test: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": {
+8
View File
@@ -146,6 +146,7 @@ CREATE TABLE IF NOT EXISTS traffic_flow_settings (
hub_server_id INTEGER, hub_server_id INTEGER,
retention_hours INTEGER NOT NULL DEFAULT 24, retention_hours INTEGER NOT NULL DEFAULT 24,
top_n INTEGER NOT NULL DEFAULT 200, top_n INTEGER NOT NULL DEFAULT 200,
map_service_min_share_pct REAL NOT NULL DEFAULT 5,
last_datagram_at TEXT, last_datagram_at TEXT,
last_exporter_ip TEXT, last_exporter_ip TEXT,
last_error TEXT, last_error TEXT,
@@ -834,6 +835,13 @@ SELECT 1, 0, '10.255.254.1', 4739, 51821, '10.255.254.0/24'
WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1); WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1);
`) `)
{
const flowSettingsCols = sqlite.prepare(`PRAGMA table_info('traffic_flow_settings')`).all() as Array<{ name?: string }>
if (!flowSettingsCols.some((c) => c.name === "map_service_min_share_pct")) {
sqlite.exec(`ALTER TABLE traffic_flow_settings ADD COLUMN map_service_min_share_pct REAL NOT NULL DEFAULT 5`)
}
}
sqlite.exec(` sqlite.exec(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days) INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14 SELECT 1, 1, 15, 14
+1
View File
@@ -173,6 +173,7 @@ export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
hubServerId: integer("hub_server_id"), hubServerId: integer("hub_server_id"),
retentionHours: integer("retention_hours").notNull().default(24), retentionHours: integer("retention_hours").notNull().default(24),
topN: integer("top_n").notNull().default(200), topN: integer("top_n").notNull().default(200),
mapServiceMinSharePct: real("map_service_min_share_pct").notNull().default(5),
lastDatagramAt: text("last_datagram_at"), lastDatagramAt: text("last_datagram_at"),
lastExporterIp: text("last_exporter_ip"), lastExporterIp: text("last_exporter_ip"),
lastError: text("last_error"), lastError: text("last_error"),
@@ -384,6 +384,51 @@ try {
} }
} }
{
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }])
ingestParsedFlowsForServerForTests(7, [
{
src: "173.194.151.65",
dst: "10.200.100.53",
proto: 6,
srcPort: 443,
dstPort: 57182,
bytes: 12_000,
packets: 10,
inIface: "2",
outIface: "2",
},
{
src: "104.18.35.51",
dst: "10.200.100.53",
proto: 6,
srcPort: 443,
dstPort: 53880,
bytes: 3_000,
packets: 4,
inIface: "2",
outIface: "2",
},
])
try {
const rev = buildFlowAnalytics({ minutes: 5, serverId: 7 })
const google = rev.conversationsList.find((r) => r.src === "173.194.151.65")
const cf = rev.conversationsList.find((r) => r.src === "104.18.35.51")
assert.equal(google?.service, "Google")
assert.equal(google?.category, "Веб")
assert.equal(cf?.service, "Cloudflare")
assert.equal(cf?.category, "CDN")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
}
{ {
resetFlowRingsForTests() resetFlowRingsForTests()
resetIfaceCacheForTests() resetIfaceCacheForTests()
@@ -30,6 +30,7 @@ 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 { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-planes.js"
import { pickInternetPeer } from "./traffic-flow-ip.js"
import { import {
enGreIfaceNames, enGreIfaceNames,
latestWireBps, latestWireBps,
@@ -171,6 +172,7 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
const pathAcc = new Map<string, FlowPathRow>() 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 peers = new Set<string>()
const matched: PendingFlowRow[] = [] const matched: PendingFlowRow[] = []
const skipHeavy = Boolean(q.skipHeavy) const skipHeavy = Boolean(q.skipHeavy)
let bytesPayload = 0 let bytesPayload = 0
@@ -217,9 +219,11 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
totalPackets += r.packets totalPackets += r.packets
srcs.add(r.src) srcs.add(r.src)
dsts.add(r.dst) dsts.add(r.dst)
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
peers.add(peer)
const app = applicationName(r.proto, r.dstPort, r.srcPort) const app = applicationName(r.proto, r.dstPort, r.srcPort)
const ripe = lookupRipeCached(r.dst) const ripe = lookupRipeCached(peer)
const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe) const classified = classifyFlowDst(peer, r.proto, r.dstPort, r.srcPort, ripe)
bump(applications, app, r.bytes, r.packets) bump(applications, app, r.bytes, r.packets)
bump(protocols, protoName(r.proto), r.bytes, r.packets) bump(protocols, protoName(r.proto), r.bytes, r.packets)
bump(sources, r.src, r.bytes, r.packets) bump(sources, r.src, r.bytes, r.packets)
@@ -345,7 +349,7 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
} }
} }
enqueueRipeMisses(dsts) enqueueRipeMisses(peers)
const conversationsList = [...conv.values()] const conversationsList = [...conv.values()]
.map((t) => { .map((t) => {
@@ -27,6 +27,9 @@ assert.equal(brandByAsn(16509)?.service, "AWS")
assert.equal(brandByAsn(57976)?.service, "Blizzard") assert.equal(brandByAsn(57976)?.service, "Blizzard")
assert.equal(brandByAsn(401115)?.service, "ChatGPT") assert.equal(brandByAsn(401115)?.service, "ChatGPT")
assert.equal(lookupBrand("1.1.1.1", 13335)?.service, "Cloudflare") assert.equal(lookupBrand("1.1.1.1", 13335)?.service, "Cloudflare")
assert.equal(lookupBrand("104.18.35.51", 0)?.service, "Cloudflare")
assert.equal(lookupBrand("173.194.151.65", 0)?.service, "Google")
assert.equal(lookupBrand("8.8.8.8", 0)?.service, "Google")
assert.equal(lookupBrand("203.0.113.9", 64500), null) assert.equal(lookupBrand("203.0.113.9", 64500), null)
assert.equal(OTHER_SERVICE, "Прочее") assert.equal(OTHER_SERVICE, "Прочее")
assert.equal(isNamedInternetService("Google", "Веб"), true) assert.equal(isNamedInternetService("Google", "Веб"), true)
+16 -6
View File
@@ -56,13 +56,23 @@ const ASN_HQ_COUNTRY = new Map<number, string>([
[211157, "NL"], [211157, "NL"],
]) ])
const GOOGLE: BrandHit = { service: "Google", category: "Веб" }
const CLOUDFLARE: BrandHit = { service: "Cloudflare", category: "CDN" }
const YOUTUBE: BrandHit = { service: "YouTube", category: "Видео / стриминг" }
const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [ const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: { service: "Cloudflare", category: "CDN" } }, { cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: { service: "Cloudflare", category: "CDN" } }, { cidr: "104.24.0.0/14", prefixLen: 14, hit: CLOUDFLARE },
{ cidr: "172.64.0.0/13", prefixLen: 13, hit: { service: "Cloudflare", category: "CDN" } }, { cidr: "172.64.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
{ cidr: "162.158.0.0/15", prefixLen: 15, hit: { service: "Cloudflare", category: "CDN" } }, { cidr: "162.158.0.0/15", prefixLen: 15, hit: CLOUDFLARE },
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: { service: "YouTube", category: "Видео / стриминг" } }, { cidr: "8.8.8.0/24", prefixLen: 24, hit: GOOGLE },
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: { service: "YouTube", category: "Видео / стриминг" } }, { cidr: "8.8.4.0/24", prefixLen: 24, hit: GOOGLE },
{ cidr: "173.194.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "172.217.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "74.125.0.0/16", prefixLen: 16, hit: GOOGLE },
{ cidr: "142.250.0.0/15", prefixLen: 15, hit: GOOGLE },
{ cidr: "208.65.152.0/22", prefixLen: 22, hit: YOUTUBE },
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
].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"])
@@ -36,6 +36,10 @@ const google = classifyFlowDst("173.194.160.163", 6, 443, 1, {
assert.equal(google.service, "Google") assert.equal(google.service, "Google")
assert.equal(google.category, "Веб") assert.equal(google.category, "Веб")
const googleCidr = classifyFlowDst("173.194.151.65", 6, 57182, 443, null)
assert.equal(googleCidr.service, "Google")
assert.equal(googleCidr.category, "Веб")
const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, { const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, {
prefix: "173.194.0.0/16", prefix: "173.194.0.0/16",
asn: 15169, asn: 15169,
+5 -3
View File
@@ -7,6 +7,7 @@ import { classifyFlowDst } from "./traffic-flow-classify.js"
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js" import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
import { isIsoCountry } from "./traffic-flow-brands.js" import { isIsoCountry } from "./traffic-flow-brands.js"
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js" import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
import { pickInternetPeer } from "./traffic-flow-ip.js"
type SqliteHandle = InstanceType<typeof Database> type SqliteHandle = InstanceType<typeof Database>
@@ -239,9 +240,10 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
const flow = normalizeParsedFlow(raw) const flow = normalizeParsedFlow(raw)
addToTick(serverId, flow, flow.bytes) 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 peer = pickInternetPeer(flow.src, flow.dst, flow.srcPort, flow.dstPort)
if (flow.dst && !ripe) ripeMisses.push(flow.dst) const ripe = lookupRipeCached(peer)
const classified = classifyFlowDst(flow.dst, flow.proto, flow.dstPort, flow.srcPort, ripe) if (peer && !ripe) ripeMisses.push(peer)
const classified = classifyFlowDst(peer, flow.proto, flow.dstPort, flow.srcPort, ripe)
const app = applicationName(flow.proto, flow.dstPort, flow.srcPort) const app = applicationName(flow.proto, flow.dstPort, flow.srcPort)
const country = ripe?.ok && isIsoCountry(ripe.country) const country = ripe?.ok && isIsoCountry(ripe.country)
? ripe.country ? ripe.country
@@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import { isNonPublicIp, pickInternetPeer } from "./traffic-flow-ip.js"
assert.equal(isNonPublicIp("10.200.100.53"), true)
assert.equal(isNonPublicIp("173.194.151.65"), false)
assert.equal(
pickInternetPeer("173.194.151.65", "10.200.100.53", 443, 57182),
"173.194.151.65",
"reverse IPFIX: Google:443 → RFC1918",
)
assert.equal(
pickInternetPeer("10.200.100.53", "104.18.35.51", 53880, 443),
"104.18.35.51",
"client → Cloudflare:443",
)
assert.equal(pickInternetPeer("10.100.1.17", "8.8.8.8", 51234, 443), "8.8.8.8")
assert.equal(
pickInternetPeer("1.1.1.1", "8.8.8.8", 443, 51234),
"1.1.1.1",
"оба публичные — сторона с well-known портом",
)
assert.equal(pickInternetPeer("10.1.1.1", "10.2.2.2", 443, 80), "10.2.2.2")
console.log("traffic-flow-ip.test.ts: ok")
+20
View File
@@ -52,3 +52,23 @@ export function isNonPublicIp(ip: string): boolean {
|| inRange("255.255.255.255/32") || inRange("255.255.255.255/32")
) )
} }
const PEER_WELL_KNOWN_PORTS = new Set([80, 443, 53, 853])
/**
* Интернет-сторона потока: у IPFIX сервис часто в src (Google:443 → RFC1918:ephemeral).
* Классифицировать этот IP, не слепой dst.
*/
export function pickInternetPeer(src: string, dst: string, srcPort: number, dstPort: number): string {
const srcPub = !isNonPublicIp(src)
const dstPub = !isNonPublicIp(dst)
if (srcPub && !dstPub) return src
if (dstPub && !srcPub) return dst
if (srcPub && dstPub) {
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPort)
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPort)
if (srcWk && !dstWk) return src
if (dstWk && !srcWk) return dst
}
return dst
}
@@ -4,7 +4,7 @@ import {
ingestParsedFlowsForServerForTests, ingestParsedFlowsForServerForTests,
resetFlowRingsForTests, resetFlowRingsForTests,
} from "./traffic-flow-ingest.js" } from "./traffic-flow-ingest.js"
import { buildFlowMapHops } from "./traffic-flow-map-hops.js" import { buildFlowMapHops, resetFlowMapHopsCacheForTests } from "./traffic-flow-map-hops.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js" import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js" import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
import { import {
@@ -114,6 +114,7 @@ ingestParsedFlowsForServerForTests(3, [
]) ])
try { try {
resetFlowMapHopsCacheForTests()
const def = buildFlowMapHops({ minutes: 5 }) const def = buildFlowMapHops({ minutes: 5 })
assert.equal(def.excludeOverlayApplied, true) assert.equal(def.excludeOverlayApplied, true)
assert.equal(def.excludeMeshApplied, true) assert.equal(def.excludeMeshApplied, true)
@@ -142,6 +143,7 @@ try {
assert.ok(wan, "WAN hop from home-router") assert.ok(wan, "WAN hop from home-router")
assert.equal(wan.bytes, 3000) assert.equal(wan.bytes, 3000)
resetFlowMapHopsCacheForTests()
const withAll = buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false }) const withAll = buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false })
const overlayIface = withAll.hops.find((h) => h.iface === "gre-jh-en" && h.fromId === "7") const overlayIface = withAll.hops.find((h) => h.iface === "gre-jh-en" && h.fromId === "7")
assert.ok(overlayIface && overlayIface.bytes >= 5_000_000) assert.ok(overlayIface && overlayIface.bytes >= 5_000_000)
@@ -200,12 +202,15 @@ ingestParsedFlowsForServerForTests(7, [
payloadFlow("203.0.113.50", 9400), payloadFlow("203.0.113.50", 9400),
]) ])
try { try {
const six = buildFlowMapHops({ minutes: 5 }) resetFlowMapHopsCacheForTests()
const six = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(six.totalBytes, 10_000) assert.equal(six.totalBytes, 10_000)
const google = six.services?.find((s) => s.id === "svc:google") const google = six.services?.find((s) => s.id === "svc:google")
assert.ok(google, "Google ≥ 5%") assert.ok(google, "Google ≥ 5%")
assert.ok(google.share >= 0.05) assert.ok(google.share >= 0.05)
assert.ok(six.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9")) const googleEdge = six.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
assert.ok(googleEdge)
assert.equal(googleEdge.clientName, "Alice")
} finally { } finally {
resetFlowRingsForTests() resetFlowRingsForTests()
resetIfaceCacheForTests() resetIfaceCacheForTests()
@@ -227,9 +232,13 @@ ingestParsedFlowsForServerForTests(7, [
payloadFlow("203.0.113.50", 9600), payloadFlow("203.0.113.50", 9600),
]) ])
try { try {
const four = buildFlowMapHops({ minutes: 5 }) resetFlowMapHopsCacheForTests()
const four = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(four.totalBytes, 10_000) assert.equal(four.totalBytes, 10_000)
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden") assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
resetFlowMapHopsCacheForTests()
const off = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google 4%")
} finally { } finally {
resetFlowRingsForTests() resetFlowRingsForTests()
resetIfaceCacheForTests() resetIfaceCacheForTests()
@@ -260,7 +269,8 @@ ingestParsedFlowsForServerForTests(7, [
payloadFlow("203.0.113.50", 1000), payloadFlow("203.0.113.50", 1000),
]) ])
try { try {
const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false }) resetFlowMapHopsCacheForTests()
const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false, minSharePct: 0 })
assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service") assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service")
} finally { } finally {
seedFlowTopologyForTests(null) seedFlowTopologyForTests(null)
@@ -270,4 +280,95 @@ try {
resetFlowCatalogForTests() resetFlowCatalogForTests()
} }
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
{
src: "173.194.151.65",
dst: "10.200.100.53",
proto: 6,
srcPort: 443,
dstPort: 57182,
bytes: 9_000,
packets: 90,
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
},
{
src: "104.18.35.51",
dst: "10.200.100.53",
proto: 6,
srcPort: 443,
dstPort: 53880,
bytes: 1_000,
packets: 10,
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
},
])
try {
resetFlowMapHopsCacheForTests()
const rev = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
assert.ok(rev.services?.some((s) => s.id === "svc:google"), "реверс Google:443 → 10.x")
assert.ok(rev.services?.some((s) => s.id === "svc:cloudflare"), "реверс Cloudflare:443 → 10.x")
assert.ok(rev.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9"))
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*1", name: "SWE-VEESP" },
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 500),
{
src: "173.194.151.65",
dst: "10.200.100.53",
proto: 6,
srcPort: 443,
dstPort: 57182,
bytes: 8_000,
packets: 80,
inIface: "1",
outIface: "1",
nextHop: "",
},
])
try {
resetFlowMapHopsCacheForTests()
const wan = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const googleEdge = wan.serviceEdges?.find((e) => e.toId === "svc:google")
assert.ok(googleEdge, "Google с WAN JH")
assert.equal(googleEdge.fromId, "9", "якорь на EN, не на JH")
assert.ok(!(wan.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH")
const viaGre = wan.serviceEdges?.find((e) => e.toId === "svc:google")
assert.ok(viaGre?.clients?.some((c) => c.name === "Alice") || viaGre?.clientName === "Alice")
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
console.log("traffic-flow-map-hops.test.ts: ok") console.log("traffic-flow-map-hops.test.ts: ok")
+185 -41
View File
@@ -2,20 +2,24 @@ import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow" import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { servers, userInterfaceBindings } from "../db/schema.js" import { servers, userInterfaceBindings } from "../db/schema.js"
import { flowRowMatchesFilter } from "./traffic-flow-apps.js" import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { import {
isNamedInternetService, isNamedInternetService,
lookupBrand,
mapServiceNodeId, mapServiceNodeId,
} from "./traffic-flow-brands.js" } from "./traffic-flow-brands.js"
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js" import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js" import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js" import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js" import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js" import { pickInternetPeer } from "./traffic-flow-ip.js"
import { loadFlowTopology, resolveEn } from "./traffic-flow-topology.js" import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { loadFlowTopology, resolveClient, resolveEn } from "./traffic-flow-topology.js"
export const MAP_SERVICE_SHARE_THRESHOLD = 0.05 export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
export const MAP_SERVICE_NODE_CAP = 20
const HOPS_CACHE_TTL_MS = 2000
export interface FlowMapHopsQuery { export interface FlowMapHopsQuery {
minutes: number minutes: number
@@ -25,6 +29,8 @@ export interface FlowMapHopsQuery {
dedup?: boolean dedup?: boolean
excludeMesh?: boolean excludeMesh?: boolean
excludeOverlay?: boolean excludeOverlay?: boolean
/** Переопределение порога (тесты). Иначе из настроек NetFlow. */
minSharePct?: number
} }
interface HopAcc { interface HopAcc {
@@ -39,6 +45,56 @@ interface HopAcc {
bytesRev: number bytesRev: number
} }
interface FromAcc {
bytes: number
clients: Map<string, string>
}
interface DstAcc {
bytes: number
proto: number
dstPort: number
srcPort: number
fromBytes: Map<string, FromAcc>
}
function bumpFrom(acc: DstAcc, exporterId: string, bytes: number, client: { userId: string; name: string } | null): void {
const prev = acc.fromBytes.get(exporterId)
if (prev) {
prev.bytes += bytes
if (client) prev.clients.set(client.userId, client.name)
return
}
const clients = new Map<string, string>()
if (client) clients.set(client.userId, client.name)
acc.fromBytes.set(exporterId, { bytes, clients })
}
let hopsCache: { key: string; at: number; dto: FlowMapHopsDto } | null = null
export function resetFlowMapHopsCacheForTests(): void {
hopsCache = null
}
export function clampMapServiceMinSharePct(n: unknown): number {
const v = typeof n === "number" ? n : Number(n)
if (!Number.isFinite(v)) return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
return Math.min(100, Math.max(0, v))
}
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
return JSON.stringify({
minutes: q.minutes,
serverId: q.serverId ?? null,
userId: q.userId ?? null,
iface: q.iface ?? null,
dedup: q.dedup !== false,
excludeMesh: q.excludeMesh !== false,
excludeOverlay: q.excludeOverlay !== false,
minSharePct,
})
}
function userIfaceAllow(userId: string): Map<number, Set<string>> | null { function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
if (!userId) return null if (!userId) return null
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all() const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
@@ -89,8 +145,36 @@ function toHop(a: HopAcc, windowSec: number): FlowMapHop {
} }
} }
/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */ /** Имя бренда без каталога EvoBGP — только ASN/CIDR кэш + proto. */
export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto { function classifyMapDstLite(
dst: string,
proto: number,
dstPort: number,
srcPort: number,
ripe: FlowIpMeta | null,
): { service: string; category: string } | null {
if (proto === 47 || proto === 50) return null
const app = applicationName(proto, dstPort, srcPort)
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
if (/youtube/i.test(ripe?.holder ?? "")) {
return { service: "YouTube", category: "Видео / стриминг" }
}
const brand = lookupBrand(dst, ripe?.asn ?? 0)
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
return brand
}
function resolveMinSharePct(q: FlowMapHopsQuery): number {
if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct)
try {
const row = getTrafficFlowSettingsRow() as { mapServiceMinSharePct?: number }
return clampMapServiceMinSharePct(row.mapServiceMinSharePct ?? DEFAULT_MAP_SERVICE_MIN_SHARE_PCT)
} catch {
return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
}
}
function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): FlowMapHopsDto {
const windowSec = Math.max(60, q.minutes * 60) const windowSec = Math.max(60, q.minutes * 60)
const raw = listFlowRowsForWindow(q.minutes) const raw = listFlowRowsForWindow(q.minutes)
const allow = q.userId ? userIfaceAllow(q.userId) : null const allow = q.userId ? userIfaceAllow(q.userId) : null
@@ -122,13 +206,11 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
const hops = new Map<string, HopAcc>() const hops = new Map<string, HopAcc>()
const svcTotals = new Map<string, { label: string; category: string; bytes: number }>() const dstAcc = new Map<string, DstAcc>()
const svcEdges = new Map<string, { fromId: string; toId: string; bytes: number; bytesFwd: number; bytesRev: number }>() const jhToEn = new Map<number, number>()
const dsts = new Set<string>() const enIds = new Set(topo.enNodes.map((n) => n.id))
let totalBytes = 0 let totalBytes = 0
refreshFlowCatalogInBackground()
for (const r of working) { for (const r of working) {
const inRes = resolveIfaceName(r.serverId, r.inIface) const inRes = resolveIfaceName(r.serverId, r.inIface)
const outRes = resolveIfaceName(r.serverId, r.outIface) const outRes = resolveIfaceName(r.serverId, r.outIface)
@@ -178,6 +260,7 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
const en = (enOut && enOut.id !== r.serverId ? enOut : null) const en = (enOut && enOut.id !== r.serverId ? enOut : null)
?? (enIn && enIn.id !== r.serverId ? enIn : null) ?? (enIn && enIn.id !== r.serverId ? enIn : null)
if (en) { if (en) {
jhToEn.set(r.serverId, en.id)
const toId = String(en.id) const toId = String(en.id)
const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev" const dir: "fwd" | "rev" = enOut && enOut.id === en.id ? "fwd" : "rev"
const greIface = dir === "fwd" && ifaceUsable(outName) ? outName : (ifaceUsable(inName) ? inName : undefined) const greIface = dir === "fwd" && ifaceUsable(outName) ? outName : (ifaceUsable(inName) ? inName : undefined)
@@ -215,37 +298,75 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
} }
totalBytes += r.bytes totalBytes += r.bytes
dsts.add(r.dst) const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
const ripe = lookupRipeCached(r.dst) const client = resolveClient(topo, r.serverId, inName)
const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe) const prevDst = dstAcc.get(peer)
if (isNamedInternetService(classified.service, classified.category)) { if (prevDst) {
const toId = mapServiceNodeId(classified.service) prevDst.bytes += r.bytes
const prevSvc = svcTotals.get(toId) bumpFrom(prevDst, String(r.serverId), r.bytes, client)
if (prevSvc) prevSvc.bytes += r.bytes } else {
else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: r.bytes }) const acc: DstAcc = {
bytes: r.bytes,
proto: r.proto,
dstPort: r.dstPort,
srcPort: r.srcPort,
fromBytes: new Map(),
}
bumpFrom(acc, String(r.serverId), r.bytes, client)
dstAcc.set(peer, acc)
}
}
const svcEn = enOut ?? enIn const svcTotals = new Map<string, { label: string; category: string; bytes: number }>()
const svcFromId = svcEn ? String(svcEn.id) : fromId const svcEdges = new Map<string, {
const edgeKey = `${svcFromId}|${toId}` fromId: string
toId: string
bytes: number
bytesFwd: number
bytesRev: number
clients: Map<string, string>
}>()
function anchorEnId(exporterId: string): string | null {
const n = Number(exporterId)
if (enIds.has(n)) return exporterId
const mapped = jhToEn.get(n)
if (mapped != null) return String(mapped)
return null
}
for (const [dst, acc] of dstAcc) {
const ripe = lookupRipeCached(dst)
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
if (!classified) continue
const toId = mapServiceNodeId(classified.service)
const prevSvc = svcTotals.get(toId)
if (prevSvc) prevSvc.bytes += acc.bytes
else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: acc.bytes })
for (const [exporterId, from] of acc.fromBytes) {
const fromId = anchorEnId(exporterId)
if (!fromId) continue
const edgeKey = `${fromId}|${toId}`
const prevEdge = svcEdges.get(edgeKey) const prevEdge = svcEdges.get(edgeKey)
if (prevEdge) { if (prevEdge) {
prevEdge.bytes += r.bytes prevEdge.bytes += from.bytes
prevEdge.bytesFwd += r.bytes prevEdge.bytesFwd += from.bytes
for (const [id, name] of from.clients) prevEdge.clients.set(id, name)
} else { } else {
svcEdges.set(edgeKey, { svcEdges.set(edgeKey, {
fromId: svcFromId, fromId,
toId, toId,
bytes: r.bytes, bytes: from.bytes,
bytesFwd: r.bytes, bytesFwd: from.bytes,
bytesRev: 0, bytesRev: 0,
clients: new Map(from.clients),
}) })
} }
} }
} }
enqueueRipeMisses(dsts) const minShare = minSharePct / 100
let services: FlowMapService[] = [...svcTotals.entries()]
const services: FlowMapService[] = [...svcTotals.entries()]
.map(([id, s]) => ({ .map(([id, s]) => ({
id, id,
label: s.label, label: s.label,
@@ -254,34 +375,57 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
bps: (s.bytes * 8) / windowSec, bps: (s.bytes * 8) / windowSec,
share: totalBytes > 0 ? s.bytes / totalBytes : 0, share: totalBytes > 0 ? s.bytes / totalBytes : 0,
})) }))
.filter((s) => s.share >= MAP_SERVICE_SHARE_THRESHOLD)
.sort((a, b) => b.bytes - a.bytes) .sort((a, b) => b.bytes - a.bytes)
if (minSharePct > 0) {
services = services.filter((s) => s.share >= minShare)
}
services = services.slice(0, MAP_SERVICE_NODE_CAP)
const keepSvc = new Set(services.map((s) => s.id)) const keepSvc = new Set(services.map((s) => s.id))
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()] const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
.filter((e) => keepSvc.has(e.toId)) .filter((e) => keepSvc.has(e.toId))
.map((e) => ({ .map((e) => {
fromId: e.fromId, const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
toId: e.toId, const first = clients[0]
bytes: e.bytes, return {
bps: (e.bytes * 8) / windowSec, fromId: e.fromId,
bpsFwd: (e.bytesFwd * 8) / windowSec, toId: e.toId,
bpsRev: (e.bytesRev * 8) / windowSec, bytes: e.bytes,
})) bps: (e.bytes * 8) / windowSec,
bpsFwd: (e.bytesFwd * 8) / windowSec,
bpsRev: (e.bytesRev * 8) / windowSec,
...(first ? { clientId: first.id, clientName: first.name } : {}),
...(clients.length ? { clients } : {}),
}
})
.sort((a, b) => b.bytes - a.bytes) .sort((a, b) => b.bytes - a.bytes)
const listener = getFlowListenerState() const listener = getFlowListenerState()
return { return {
hops: [...hops.values()] hops: [...hops.values()]
.map((a) => toHop(a, windowSec)) .map((a) => toHop(a, windowSec))
.sort((a, b) => b.bytes - a.bytes), .sort((a, b) => a.bytes === b.bytes ? 0 : b.bytes - a.bytes),
live: listener.bound, live: listener.bound,
rangeMinutes: q.minutes, rangeMinutes: q.minutes,
windowSec, windowSec,
totalBytes, totalBytes,
services, services,
serviceEdges, serviceEdges,
mapServiceMinSharePct: minSharePct,
dedupApplied: wantDedup, dedupApplied: wantDedup,
excludeMeshApplied: excludeMesh, excludeMeshApplied: excludeMesh,
excludeOverlayApplied: excludeOverlay, excludeOverlayApplied: excludeOverlay,
} }
} }
/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */
export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
const minSharePct = resolveMinSharePct(q)
const key = hopsQueryKey(q, minSharePct)
const now = Date.now()
if (hopsCache && hopsCache.key === key && now - hopsCache.at < HOPS_CACHE_TTL_MS) {
return hopsCache.dto
}
const dto = buildFlowMapHopsUncached(q, minSharePct)
hopsCache = { key, at: now, dto }
return dto
}
@@ -7,6 +7,7 @@ import {
lookupRipeCached, lookupRipeCached,
resetRipeCacheForTests, resetRipeCacheForTests,
ripeFetchCountForTests, ripeFetchCountForTests,
ripeLastCandidateCountForTests,
seedRipeCacheForTests, seedRipeCacheForTests,
setRipeFetchForTests, setRipeFetchForTests,
} from "./traffic-flow-ripe.js" } from "./traffic-flow-ripe.js"
@@ -100,4 +101,36 @@ await flushRipeQueueForTests()
assert.equal(lookupRipeCached("1.0.0.1")?.country, "US") assert.equal(lookupRipeCached("1.0.0.1")?.country, "US")
assert.equal(lookupRipeCached("1.0.0.1")?.asn, 13335) assert.equal(lookupRipeCached("1.0.0.1")?.asn, 13335)
resetRipeCacheForTests()
disableRipePersistForTests()
for (let i = 0; i < 3000; i++) {
const o2 = Math.floor(i / 256)
const o3 = i % 256
seedRipeCacheForTests({
prefix: `203.${o2}.${o3}.0/24`,
asn: 64500,
country: "NL",
lat: null,
lng: null,
holder: "NOISE",
ok: true,
fetchedAt: Date.now(),
})
}
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(lookupRipeCached("8.8.8.8")?.asn, 15169)
assert.ok(
ripeLastCandidateCountForTests() < 8,
`index should not scan all prefixes, got ${ripeLastCandidateCountForTests()}`,
)
console.log("traffic-flow-ripe.test.ts: ok") console.log("traffic-flow-ripe.test.ts: ok")
+79 -13
View File
@@ -1,5 +1,5 @@
import { sqliteDatabase } from "../db/index.js" import { sqliteDatabase } from "../db/index.js"
import { ipInCidrV4, ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js" import { ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
import { resolveRipeCountry } from "./traffic-flow-brands.js" import { resolveRipeCountry } from "./traffic-flow-brands.js"
export interface FlowIpMeta { export interface FlowIpMeta {
@@ -28,6 +28,18 @@ const queue: string[] = []
const queued = new Set<string>() const queued = new Set<string>()
const recentFetches: number[] = [] const recentFetches: number[] = []
interface RipeIndexed {
entry: FlowIpMeta
net: number
mask: number
prefixLen: number
}
/** /24 → кандидаты с prefixLen ≥ 24. Более широкие префиксы — в `wideIndex`. */
const v24Index = new Map<number, RipeIndexed[]>()
const wideIndex: RipeIndexed[] = []
let lastCandidateCount = 0
let persistEnabled = true let persistEnabled = true
let enqueueEnabled = true let enqueueEnabled = true
let loaded = false let loaded = false
@@ -50,6 +62,9 @@ export function resetRipeCacheForTests(): void {
queue.length = 0 queue.length = 0
queued.clear() queued.clear()
recentFetches.length = 0 recentFetches.length = 0
v24Index.clear()
wideIndex.length = 0
lastCandidateCount = 0
loaded = persistEnabled ? false : true loaded = persistEnabled ? false : true
workerRunning = false workerRunning = false
fetchCount = 0 fetchCount = 0
@@ -58,10 +73,15 @@ export function resetRipeCacheForTests(): void {
} }
export function seedRipeCacheForTests(entry: FlowIpMeta): void { export function seedRipeCacheForTests(entry: FlowIpMeta): void {
mem.set(entry.prefix, { ...entry }) remember(entry)
loaded = true loaded = true
} }
/** Сколько CIDR смотрели в последнем lookup (для теста индекса /24). */
export function ripeLastCandidateCountForTests(): number {
return lastCandidateCount
}
export function setRipeFetchForTests(fn: typeof fetch): void { export function setRipeFetchForTests(fn: typeof fetch): void {
fetchImpl = fn fetchImpl = fn
fetchCount = 0 fetchCount = 0
@@ -87,6 +107,48 @@ function isFresh(entry: FlowIpMeta): boolean {
return Date.now() - entry.fetchedAt < ttlMs(entry.ok) return Date.now() - entry.fetchedAt < ttlMs(entry.ok)
} }
function unindexPrefix(prefix: string): void {
const parsed = parseCidrV4(prefix)
if (!parsed) return
if (parsed.prefixLen >= 24) {
const key = parsed.net >>> 8
const list = v24Index.get(key)
if (!list) return
const next = list.filter((row) => row.entry.prefix !== prefix)
if (next.length) v24Index.set(key, next)
else v24Index.delete(key)
return
}
const idx = wideIndex.findIndex((row) => row.entry.prefix === prefix)
if (idx >= 0) wideIndex.splice(idx, 1)
}
function indexEntry(entry: FlowIpMeta): void {
const parsed = parseCidrV4(entry.prefix)
if (!parsed) return
const row: RipeIndexed = {
entry,
net: parsed.net,
mask: parsed.mask,
prefixLen: parsed.prefixLen,
}
if (parsed.prefixLen >= 24) {
const key = parsed.net >>> 8
const list = v24Index.get(key)
if (list) list.push(row)
else v24Index.set(key, [row])
return
}
wideIndex.push(row)
}
function remember(entry: FlowIpMeta): void {
const prev = mem.get(entry.prefix)
if (prev) unindexPrefix(prev.prefix)
mem.set(entry.prefix, entry)
indexEntry(entry)
}
function loadSqlite(): void { function loadSqlite(): void {
if (loaded || !persistEnabled) { if (loaded || !persistEnabled) {
loaded = true loaded = true
@@ -111,7 +173,7 @@ function loadSqlite(): void {
const fetchedAt = Date.parse(r.fetched_at) const fetchedAt = Date.parse(r.fetched_at)
const asn = Number(r.asn ?? 0) || 0 const asn = Number(r.asn ?? 0) || 0
const holder = r.holder || "" const holder = r.holder || ""
mem.set(r.prefix, { remember({
prefix: r.prefix, prefix: r.prefix,
asn, asn,
country: resolveRipeCountry(r.country || "", asn, holder) || "—", country: resolveRipeCountry(r.country || "", asn, holder) || "—",
@@ -193,20 +255,24 @@ function negative(prefix: string): FlowIpMeta {
export function lookupRipeCached(ip: string): FlowIpMeta | null { export function lookupRipeCached(ip: string): FlowIpMeta | null {
loadSqlite() loadSqlite()
const trimmed = String(ip ?? "").trim() const trimmed = String(ip ?? "").trim()
lastCandidateCount = 0
if (!trimmed) return null if (!trimmed) return null
if (isNonPublicIp(trimmed)) { if (isNonPublicIp(trimmed)) {
return negative(`${trimmed.includes(":") ? trimmed : trimmed}/32`) return negative(`${trimmed.includes(":") ? trimmed : trimmed}/32`)
} }
const addr = ipv4ToInt(trimmed)
if (addr == null) return null
const bucket = v24Index.get(addr >>> 8)
const candidates = bucket ? bucket.concat(wideIndex) : wideIndex
lastCandidateCount = candidates.length
let best: FlowIpMeta | null = null let best: FlowIpMeta | null = null
let bestLen = -1 let bestLen = -1
for (const entry of mem.values()) { for (const row of candidates) {
if (!isFresh(entry)) continue if (!isFresh(row.entry)) continue
const parsed = parseCidrV4(entry.prefix) if (((addr & row.mask) >>> 0) !== row.net) continue
if (!parsed) continue if (row.prefixLen > bestLen) {
if (!ipInCidrV4(trimmed, entry.prefix)) continue best = row.entry
if (parsed.prefixLen > bestLen) { bestLen = row.prefixLen
best = entry
bestLen = parsed.prefixLen
} }
} }
return best return best
@@ -316,13 +382,13 @@ async function resolveIp(ip: string): Promise<FlowIpMeta | null> {
ok: Boolean(asn || country), ok: Boolean(asn || country),
fetchedAt: Date.now(), fetchedAt: Date.now(),
} }
mem.set(prefix, entry) remember(entry)
persist(entry) persist(entry)
return entry return entry
} catch { } catch {
const prefix = `${ip}/32` const prefix = `${ip}/32`
const entry = negative(prefix) const entry = negative(prefix)
mem.set(prefix, entry) remember(entry)
persist(entry) persist(entry)
return entry return entry
} finally { } finally {
@@ -53,6 +53,7 @@ export function toTrafficFlowSettingsDto(
hubServerId: row.hubServerId ?? null, hubServerId: row.hubServerId ?? null,
retentionHours: row.retentionHours, retentionHours: row.retentionHours,
topN: row.topN, topN: row.topN,
mapServiceMinSharePct: Number(row.mapServiceMinSharePct ?? 5),
lastDatagramAt: row.lastDatagramAt ?? null, lastDatagramAt: row.lastDatagramAt ?? null,
lastExporterIp: row.lastExporterIp ?? null, lastExporterIp: row.lastExporterIp ?? null,
lastError: row.lastError || null, lastError: row.lastError || null,
@@ -75,6 +76,9 @@ export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId, hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId,
retentionHours: patch.retentionHours ?? row.retentionHours, retentionHours: patch.retentionHours ?? row.retentionHours,
topN: patch.topN ?? row.topN, topN: patch.topN ?? row.topN,
mapServiceMinSharePct: patch.mapServiceMinSharePct == null
? row.mapServiceMinSharePct
: Math.min(100, Math.max(0, patch.mapServiceMinSharePct)),
updatedAt: nowIso(), updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run() }).where(eq(trafficFlowSettings.id, 1)).run()
return getTrafficFlowSettingsRow() return getTrafficFlowSettingsRow()
@@ -36,12 +36,12 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n
) )
case "google": case "google":
return ( return (
<BrandSvg size={size}> <svg width={size} height={size} viewBox="0 0 48 48" aria-hidden>
<path d="M12 11.2h10.2A10 10 0 1 0 12 22a9.6 9.6 0 0 0 6.6-2.6l-2.7-2.1A5.8 5.8 0 1 1 12 6.2c1.5 0 2.8.5 3.8 1.5l2.6-2.6A9.8 9.8 0 0 0 12 2Z" fill="#4285F4" /> <path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.7 32.7 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6.1 29.3 4 24 4 13 4 4 13 4 24s8.9 20 20 20c11 0 20-9 20-20 0-1.3-.1-2.7-.4-3.9z" />
<path d="M3.2 7.2 6.6 9.7A5.8 5.8 0 0 1 16 8.2l2.7-2.6A9.8 9.8 0 0 0 3.2 7.2Z" fill="#EA4335" /> <path fill="#FF3D00" d="M6.3 14.7 12.9 19.5C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z" />
<path d="M12 22a9.6 9.6 0 0 0 6.6-2.6l-2.7-2.1A5.8 5.8 0 0 1 6.5 14.4L3 17A9.8 9.8 0 0 0 12 22Z" fill="#34A853" /> <path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-7.9l-6.5 5C9.5 39.6 16.2 44 24 44z" />
<path d="M21.8 12.2H12v3.6h5.6A5.5 5.5 0 0 1 15.9 17.3l2.7 2.1A9.4 9.4 0 0 0 22 12.2Z" fill="#FBBC05" /> <path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C36.9 39.2 44 34 44 24c0-1.3-.1-2.7-.4-3.9z" />
</BrandSvg> </svg>
) )
case "aws": case "aws":
case "amazon": case "amazon":
@@ -47,6 +47,8 @@ function NetflowSettingsPanel({
const [endpoint, setEndpoint] = useState("") const [endpoint, setEndpoint] = useState("")
const [retention, setRetention] = useState("24") const [retention, setRetention] = useState("24")
const [topN, setTopN] = useState("200") const [topN, setTopN] = useState("200")
const [shareOn, setShareOn] = useState(true)
const [sharePct, setSharePct] = useState("5")
const [ingestOn, setIngestOn] = useState(false) const [ingestOn, setIngestOn] = useState(false)
const [purgeOpen, setPurgeOpen] = useState(false) const [purgeOpen, setPurgeOpen] = useState(false)
const [purgeBusy, setPurgeBusy] = useState(false) const [purgeBusy, setPurgeBusy] = useState(false)
@@ -62,6 +64,9 @@ function NetflowSettingsPanel({
setEndpoint(s.publicEndpoint) setEndpoint(s.publicEndpoint)
setRetention(String(s.retentionHours)) setRetention(String(s.retentionHours))
setTopN(String(s.topN)) setTopN(String(s.topN))
const pct = Number(s.mapServiceMinSharePct ?? 5)
setShareOn(pct > 0)
setSharePct(String(pct > 0 ? pct : 5))
setIngestOn(s.enabled) setIngestOn(s.enabled)
}, [backendUrl, enabled]) }, [backendUrl, enabled])
@@ -83,6 +88,9 @@ function NetflowSettingsPanel({
publicEndpoint: endpoint, publicEndpoint: endpoint,
retentionHours: Number.parseInt(retention, 10) || 24, retentionHours: Number.parseInt(retention, 10) || 24,
topN: Number.parseInt(topN, 10) || 200, topN: Number.parseInt(topN, 10) || 200,
mapServiceMinSharePct: shareOn
? Math.min(100, Math.max(1, Number.parseFloat(sharePct) || 5))
: 0,
}) })
setSettings(res.settings) setSettings(res.settings)
toast.success("Настройки NetFlow сохранены") toast.success("Настройки NetFlow сохранены")
@@ -193,6 +201,29 @@ function NetflowSettingsPanel({
<FormField label="Top-N разговоров"> <FormField label="Top-N разговоров">
<Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" /> <Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" />
</FormField> </FormField>
<div className="sm:col-span-2 flex flex-col gap-2">
<div className="flex items-center gap-3">
<FormToggle
checked={shareOn}
onChange={(on) => {
setShareOn(on)
if (on && (!sharePct || sharePct === "0")) setSharePct("5")
}}
/>
<span className="text-sm">Порог доли на карте</span>
</div>
<FormField
label="Минимум % окна"
hint="Узел сервиса, если доля байт окна ≥ N%. Выключить — показать все распознанные бренды (макс. 20)"
>
<Input
value={sharePct}
onChange={(e) => setSharePct(e.target.value)}
inputMode="decimal"
disabled={!shareOn}
/>
</FormField>
</div>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
+15
View File
@@ -14259,6 +14259,21 @@
"dependencies": { "dependencies": {
"zod": "^4.4.1" "zod": "^4.4.1"
} }
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }
+9
View File
@@ -21,6 +21,7 @@ export const trafficFlowSettingsDtoSchema = z.object({
hubServerId: z.number().int().positive().nullable(), hubServerId: z.number().int().positive().nullable(),
retentionHours: z.number().int().positive(), retentionHours: z.number().int().positive(),
topN: z.number().int().positive(), topN: z.number().int().positive(),
mapServiceMinSharePct: z.number().min(0).max(100),
lastDatagramAt: z.string().nullable(), lastDatagramAt: z.string().nullable(),
lastExporterIp: z.string().nullable(), lastExporterIp: z.string().nullable(),
lastError: z.string().nullable(), lastError: z.string().nullable(),
@@ -40,6 +41,7 @@ export const trafficFlowSettingsPatchSchema = z.object({
hubServerId: z.number().int().positive().nullable().optional(), hubServerId: z.number().int().positive().nullable().optional(),
retentionHours: z.number().int().positive().optional(), retentionHours: z.number().int().positive().optional(),
topN: z.number().int().positive().max(1000).optional(), topN: z.number().int().positive().max(1000).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
}) })
export const trafficFlowOverlayRequestSchema = z.object({ export const trafficFlowOverlayRequestSchema = z.object({
@@ -279,6 +281,12 @@ export const flowMapServiceEdgeDtoSchema = z.object({
bps: z.number().nonnegative(), bps: z.number().nonnegative(),
bpsFwd: z.number().nonnegative(), bpsFwd: z.number().nonnegative(),
bpsRev: z.number().nonnegative(), bpsRev: z.number().nonnegative(),
clientId: z.string().optional(),
clientName: z.string().optional(),
clients: z.array(z.object({
id: z.string(),
name: z.string(),
})).optional(),
}) })
export const flowMapHopsDtoSchema = z.object({ export const flowMapHopsDtoSchema = z.object({
@@ -289,6 +297,7 @@ export const flowMapHopsDtoSchema = z.object({
totalBytes: z.number().nonnegative().optional(), totalBytes: z.number().nonnegative().optional(),
services: z.array(flowMapServiceDtoSchema).optional(), services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(), serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
dedupApplied: z.boolean(), dedupApplied: z.boolean(),
excludeMeshApplied: z.boolean(), excludeMeshApplied: z.boolean(),
excludeOverlayApplied: z.boolean(), excludeOverlayApplied: z.boolean(),
+1 -1
View File
File diff suppressed because one or more lines are too long