Compare commits

...
1 Commits
Author SHA1 Message Date
DenozordecandCursor db64621122 feat(network-map): показать конечные сервисы на карте сети
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 2m0s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 45s
Docker images / publish-release (push) Successful in 12s
NetFlow ≥ 5% окна, узлы с логотипом бренда справа от EN, скорость потока на рёбрах к сервисам.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 13:30:45 +07:00
8 changed files with 703 additions and 22 deletions
+290 -16
View File
@@ -17,11 +17,14 @@ import {
buildServerResourceMap,
buildWanJhEdges,
computeNetworkMapLayout,
NETWORK_MAP_H,
NETWORK_MAP_LAYOUT_REVISION,
NETWORK_MAP_PIPELINE_Y,
NETWORK_MAP_W,
findServerByGreRemote,
greSourceWanIndexOnMap,
greTunnelProbe,
placeServiceNodes,
type GreMapEdge,
type WanJhEdge,
} from "@/lib/network-map-layout"
@@ -49,7 +52,8 @@ import {
matchNetflowForWan,
type MatchedNetflowHop,
} from "@/lib/map-netflow-hops"
import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/status-badge"
import { StatusDot } from "@/components/status-dot"
@@ -235,8 +239,8 @@ function Sparkline({ history }: { history: number[] }) {
// ─── Canvas dimensions ────────────────────────────────────────────────────────
const W = 1060
const H = 580
const W = NETWORK_MAP_W
const H = NETWORK_MAP_H
const ZOOM_MIN = 0.2
const ZOOM_MAX = 6
@@ -271,6 +275,24 @@ const TYPE_LABELS: Record<ServerType, string> = {
"home-router": "Home Router",
}
const MOCK_MAP_SERVICES: FlowMapService[] = [
{ id: "svc:google", label: "Google", category: "Веб", bytes: 22_000_000, bps: 8_800_000, share: 0.22 },
{ id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 14_000_000, bps: 5_600_000, share: 0.14 },
{ id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 },
]
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: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000 },
{ fromId: "srv2", toId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000 },
{ fromId: "srv3", toId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000 },
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000 },
]
function serviceSharePct(share: number): string {
return `${Math.round(share * 100)}%`
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function pingColor(ms: number | null) {
@@ -608,6 +630,74 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
)
}
function ServiceNode({
label,
share,
x,
y,
isSel,
isVis,
onClick,
}: {
label: string
share: number
x: number
y: number
isSel: boolean
isVis: boolean
onClick: () => void
}) {
const bw = 86
const bh = 58
return (
<g
transform={`translate(${x},${y})`}
style={{ cursor: "pointer", transition: "opacity 0.25s" }}
opacity={isVis ? 1 : 0.08}
onClick={(e) => { e.stopPropagation(); onClick() }}
>
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
{isSel && (
<rect
x={-bw / 2 - 6}
y={-bh / 2 - 6}
width={bw + 12}
height={bh + 12}
rx="12"
fill="none"
stroke="rgba(56,189,248,0.55)"
strokeWidth="1.5"
strokeDasharray="4 3"
/>
)}
<rect
x={-bw / 2}
y={-bh / 2}
width={bw}
height={bh}
rx="10"
fill="#08202c"
stroke="#22d3ee"
strokeWidth={isSel ? 2.2 : 1.4}
/>
<foreignObject x={-14} y={-24} width={28} height={28} style={{ overflow: "visible", pointerEvents: "none" }}>
<div
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28 }}
{...({ 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">
{label}
</text>
<text textAnchor="middle" y="25" fontSize="7.5" fill="#67e8f9" fontFamily="ui-monospace,monospace">
{serviceSharePct(share)}
</text>
</g>
)
}
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
x: number; y: number
wan: { name: string; isp: string; maxDl: number; maxUl: number }
@@ -723,13 +813,14 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void })
const MM_W = 172, MM_H = 94
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, onClose, onPan }: {
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
pan: { x: number; y: number }; zoom: number
nodes: (Server & { x: number; y: number })[]
greEdges: GreMapEdge[]
satPos: Record<string, { x: number; y: number }[]>
wanJhEdges: WanJhEdge[]
homeRouters: Server[]
servicePos: Record<string, { x: number; y: number }>
onClose: () => void
onPan: (x: number, y: number) => void
}) {
@@ -782,6 +873,10 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
fill={WAN_COLORS[i] + "33"} stroke={WAN_COLORS[i]} strokeWidth="3" opacity="0.7" />
))
)}
{Object.entries(servicePos).map(([id, p]) => (
<rect key={id} x={p.x - 14} y={p.y - 10} width="28" height="20" rx="4"
fill="#08202c" stroke="#22d3ee" strokeWidth="3" opacity="0.85" />
))}
{/* viewport rect */}
<rect x={pan.x} y={pan.y} width={W / zoom} height={H / zoom}
fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.6)" strokeWidth="6" rx="6" />
@@ -841,6 +936,8 @@ export default function NetworkMapPage() {
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null)
@@ -932,6 +1029,9 @@ export default function NetworkMapPage() {
setMapGreTunnels(mockGreTunnels)
setSpeedProbes([])
setGreResolvedIpv4ByHost({})
setMapHops([])
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setDataError(null)
})
return
@@ -959,6 +1059,7 @@ export default function NetworkMapPage() {
// ── Interaction ─────────────────────────────────────────────────────────────
const [selected, setSelected] = useState<Server | null>(null)
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)
@@ -993,21 +1094,44 @@ export default function NetworkMapPage() {
const [search, setSearch] = useState("")
const [showPingBadges, setShowPingBadges] = useState(true)
const [showNetflow, setShowNetflow] = useState(true)
const [showServices, setShowServices] = useState(true)
const [showAnimDots, setShowAnimDots] = useState(true)
const [showMinimap, setShowMinimap] = useState(true)
const [showHints, setShowHints] = useState(false)
const [showLayers, setShowLayers] = useState(false)
useEffect(() => {
if (!useLiveData || !showNetflow) {
queueMicrotask(() => setMapHops([]))
if (!useLiveData) {
queueMicrotask(() => {
setMapHops([])
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
})
return
}
if (!showNetflow && !showServices) {
queueMicrotask(() => {
setMapHops([])
setMapServices([])
setMapServiceEdges([])
})
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([]) })
.then((res) => {
if (cancelled) return
setMapHops(res.hops ?? [])
setMapServices(res.services ?? [])
setMapServiceEdges(res.serviceEdges ?? [])
})
.catch(() => {
if (cancelled) return
setMapHops([])
setMapServices([])
setMapServiceEdges([])
})
}
tick()
const id = window.setInterval(tick, 4000)
@@ -1015,7 +1139,7 @@ export default function NetworkMapPage() {
cancelled = true
window.clearInterval(id)
}
}, [useLiveData, showNetflow, apiFetch])
}, [useLiveData, showNetflow, showServices, apiFetch])
const effectiveSatPos = useMemo(() => {
const out: Record<string, { x: number; y: number }[]> = {}
@@ -1203,6 +1327,11 @@ export default function NetworkMapPage() {
return m
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
const visibleMapServices = showServices ? mapServices : []
const visibleServiceEdges = showServices ? mapServiceEdges.filter((e) =>
visibleMapServices.some((s) => s.id === e.toId),
) : []
const nodes = mapServers
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
// Визуальный приоритет: HR поверх JH, JH поверх EN.
@@ -1215,6 +1344,14 @@ export default function NetworkMapPage() {
})
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
const servicePosById = placeServiceNodes(
visibleMapServices.map((s) => s.id),
mapServers
.filter((s) => s.type === "exit-node")
.map((s) => nodePosById[s.id])
.filter((p): p is { x: number; y: number } => Boolean(p)),
)
// ── Refs ─────────────────────────────────────────────────────────────────────
const svgRef = useRef<SVGSVGElement>(null)
@@ -1289,7 +1426,7 @@ export default function NetworkMapPage() {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) }
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
if (e.key === "-") applyZoomCenter(1 / 1.25)
if (e.key === "0" || e.key.toLowerCase() === "f") fitView()
@@ -1377,7 +1514,7 @@ export default function NetworkMapPage() {
const moved = dragRef.current?.moved ?? false
dragRef.current = null
setIsDragging(false)
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) }
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
}
// ── Node drag start ──────────────────────────────────────────────────────
@@ -1409,12 +1546,20 @@ export default function NetworkMapPage() {
// ── Side panel ────────────────────────────────────────────────────────────
function selectServer(s: Server) {
setSelectedGreEdge(null)
setSelectedService(null)
setSelected(prev => prev?.id === s.id ? null : s)
setSelWanIdx(null)
setHoveredId(null)
}
function selectService(svc: FlowMapService) {
setSelectedGreEdge(null)
setSelected(null)
setSelWanIdx(null)
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
}
function selectWan(s: Server, wanIdx: number) {
setSelectedGreEdge(null)
setSelectedService(null)
setSelected(s)
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
}
@@ -1424,6 +1569,7 @@ export default function NetworkMapPage() {
const home = mapServers.find((s) => s.id === edge.homeId)
if (!home) return
setSelectedGreEdge(null)
setSelectedService(null)
setSelected(home)
setSelWanIdx(edge.wanIdx)
setHoveredId(null)
@@ -1565,6 +1711,7 @@ export default function NetworkMapPage() {
{([
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
{ key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" },
{ key: "showServices", label: "Сервисы", val: showServices, set: setShowServices, hint: "" },
{ key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
@@ -1705,6 +1852,7 @@ export default function NetworkMapPage() {
ev.stopPropagation()
setSelectedGreEdge(e)
setSelected(null)
setSelectedService(null)
setSelWanIdx(null)
}
return (
@@ -1845,6 +1993,50 @@ export default function NetworkMapPage() {
)
})}
{/* ── EN/JH → destination services ── */}
{visibleServiceEdges.map((edge) => {
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
const to = servicePosById[edge.toId]
if (!from || !to) return null
const hop: MatchedNetflowHop = {
bytes: edge.bytes,
bps: edge.bps,
bpsFwd: edge.bpsFwd,
bpsRev: edge.bpsRev,
}
const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16)
const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId
return (
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
<line
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
stroke="#22d3ee"
strokeWidth={hopHasRate(hop) ? 2.2 : 1.3}
strokeDasharray="5 5"
opacity="0.7"
/>
{showAnimDots && hopHasRate(hop) && (
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
<animateMotion dur="2.6s" repeatCount="indefinite"
path={`M ${from.x} ${from.y} L ${to.x} ${to.y}`} />
</circle>
)}
{hopHasRate(hop) && (
<NetflowRateBadge
mx={mx}
my={my}
hop={hop}
onOpen={(ev) => {
ev.stopPropagation()
const svc = visibleMapServices.find((s) => s.id === edge.toId)
if (svc) selectService(svc)
}}
/>
)}
</g>
)
})}
{/* ── Server nodes ── */}
{nodes.map(n => (
<ServerNode
@@ -1891,6 +2083,26 @@ export default function NetworkMapPage() {
}).filter(Boolean)
})}
{visibleMapServices.map((svc) => {
const pos = servicePosById[svc.id]
if (!pos) return null
return (
<ServiceNode
key={svc.id}
label={svc.label}
share={svc.share}
x={pos.x}
y={pos.y}
isSel={selectedService?.id === svc.id}
isVis
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectService(svc)
}}
/>
)
})}
{/* ── Hover tooltip ── */}
{hoveredNode && !isDragging && (
<SvgTooltip n={hoveredNode} />
@@ -1898,7 +2110,7 @@ export default function NetworkMapPage() {
{/* ── Legend (viewport-fixed) ── */}
<g transform={`translate(${pan.x + 14}, ${pan.y + 14})`}>
<rect width="140" height="224" rx="8"
<rect width="140" height="250" rx="8"
fill="rgba(6,13,26,0.88)" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<text x="10" y="22" fontSize="8" fontWeight="700" fill="#64748b"
fontFamily="system-ui" letterSpacing="0.08em">ЛЕГЕНДА</text>
@@ -1927,12 +2139,17 @@ export default function NetworkMapPage() {
</g>
))}
<line x1="10" y1="166" x2="130" y2="166" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<g transform="translate(10, 164)">
<rect width="14" height="14" rx="4" fill="#08202c" stroke="#22d3ee" strokeWidth="1.2" />
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">Сервис</text>
</g>
<text x="10" y="180" fontSize="7.5" fontWeight="700" fill="#475569"
<line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<text x="10" y="200" fontSize="7.5" fontWeight="700" fill="#475569"
fontFamily="system-ui" letterSpacing="0.05em">WAN АПЛИНКИ</text>
{WAN_COLORS.slice(0, 2).map((c, i) => (
<g key={i} transform={`translate(10, ${190 + i * 14})`}>
<g key={i} transform={`translate(10, ${210 + i * 14})`}>
<circle cx="5" cy="4" r="4" fill={c} opacity="0.9" />
<text x="16" y="8" fontSize="8" fill="#94a3b8" fontFamily="ui-monospace,monospace">
WAN{i + 1}
@@ -1988,6 +2205,7 @@ export default function NetworkMapPage() {
satPos={effectiveSatPos}
wanJhEdges={visibleWanJhEdges}
homeRouters={homeRouters}
servicePos={servicePosById}
onClose={() => setShowMinimap(false)}
onPan={(x, y) => setPan({ x, y })}
/>
@@ -2016,7 +2234,7 @@ export default function NetworkMapPage() {
</div>
{/* ── Side panel (узел или выбранное GRE-ребро) ── */}
{(selectedGreEdge || selected) && (
{(selectedGreEdge || selected || selectedService) && (
<div className="border-l flex flex-col overflow-hidden shrink-0 bg-background" style={{ width: 300 }}>
{selectedGreEdge ? (
<>
@@ -2222,6 +2440,62 @@ export default function NetworkMapPage() {
})()}
</div>
</>
) : selectedService ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="mt-0.5">
<ServiceBrandIcon label={selectedService.label} size={22} />
</div>
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm truncate">{selectedService.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Конечный сервис · {selectedService.category}
</p>
</div>
<button
type="button"
onClick={() => setSelectedService(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Доля окна</span>
<span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(selectedService.share)}</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium">
{formatNetflowRate({
bytes: selectedService.bytes,
bps: selectedService.bps,
bpsFwd: selectedService.bps,
bpsRev: 0,
})}
</span>
</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">
{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
const src = mapServers.find((s) => s.id === e.fromId)
return (
<div key={`${e.fromId}|${e.toId}`} className="flex items-center justify-between text-xs">
<span className="font-mono truncate">{src?.name ?? e.fromId}</span>
<span className="font-mono text-emerald-400 tabular-nums">
{formatNetflowRate({ bytes: e.bytes, bps: e.bps, bpsFwd: e.bpsFwd, bpsRev: e.bpsRev })}
</span>
</div>
)
})}
</div>
</div>
</div>
</>
) : selected ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
@@ -4,6 +4,8 @@ import {
countryFromHolder,
lookupBrand,
OTHER_SERVICE,
isNamedInternetService,
mapServiceNodeId,
resolveRipeCountry,
} from "./traffic-flow-brands.js"
@@ -21,9 +23,17 @@ 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)?.category, "Игры")
assert.equal(brandByAsn(16509)?.service, "AWS")
assert.equal(brandByAsn(57976)?.service, "Blizzard")
assert.equal(brandByAsn(401115)?.service, "ChatGPT")
assert.equal(lookupBrand("1.1.1.1", 13335)?.service, "Cloudflare")
assert.equal(lookupBrand("203.0.113.9", 64500), null)
assert.equal(OTHER_SERVICE, "Прочее")
assert.equal(isNamedInternetService("Google", "Веб"), true)
assert.equal(isNamedInternetService("Прочее", "Прочее"), false)
assert.equal(isNamedInternetService("GRE", "Туннель"), false)
assert.equal(isNamedInternetService("DNS", "DNS"), false)
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
console.log("traffic-flow-brands.test.ts: ok")
+33 -2
View File
@@ -12,11 +12,12 @@ const ASN_BRANDS = new Map<number, BrandHit>([
[209242, { service: "Cloudflare", category: "CDN" }],
[54113, { service: "Fastly", category: "CDN" }],
[20940, { service: "Akamai", category: "CDN" }],
[16509, { service: "Amazon", category: "CDN" }],
[14618, { service: "Amazon", category: "CDN" }],
[16509, { service: "AWS", category: "CDN" }],
[14618, { service: "AWS", category: "CDN" }],
[8075, { service: "Microsoft", category: "CDN" }],
[13238, { service: "Yandex", category: "CDN" }],
[32590, { service: "Steam", category: "Игры" }],
[57976, { service: "Blizzard", category: "Игры" }],
[2906, { service: "Netflix", category: "Видео / стриминг" }],
[40027, { service: "Netflix", category: "Видео / стриминг" }],
[15169, { service: "Google", category: "Веб" }],
@@ -41,6 +42,7 @@ const ASN_HQ_COUNTRY = new Map<number, string>([
[8075, "US"],
[15169, "US"],
[32590, "US"],
[57976, "US"],
[2906, "US"],
[40027, "US"],
[36040, "US"],
@@ -105,3 +107,32 @@ export function brandByCidr(ip: string): BrandHit | null {
export function lookupBrand(ip: string, asn: number): BrandHit | null {
return brandByCidr(ip) || brandByAsn(asn)
}
const SKIP_MAP_SERVICES = new Set([
OTHER_SERVICE,
"GRE",
"ESP",
"WireGuard",
"DNS",
"SSH",
"BGP",
])
const SKIP_MAP_CATEGORIES = new Set(["Туннель", "DNS", "SSH", "BGP"])
/** Именованный интернет-сервис для карты (не туннель и не «Прочее»). */
export function isNamedInternetService(service: string, category: string): boolean {
const s = service.trim()
const c = category.trim()
if (!s || SKIP_MAP_SERVICES.has(s) || SKIP_MAP_CATEGORIES.has(c)) return false
return true
}
export function mapServiceNodeId(label: string): string {
const slug = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
return `svc:${slug || "unknown"}`
}
@@ -11,6 +11,7 @@ import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
disableCatalogFetchForTests()
@@ -154,4 +155,119 @@ try {
resetFlowCatalogForTests()
}
console.log("traffic-flow-map-hops.test.ts: hops ok")
function googleRipe() {
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: 37.4,
lng: -122.1,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
}
function payloadFlow(dst: string, bytes: number) {
return {
src: "10.100.1.17",
dst,
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes,
packets: Math.max(1, Math.round(bytes / 1200)),
inIface: "2",
outIface: "3",
nextHop: "198.51.100.1",
}
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 600),
payloadFlow("203.0.113.50", 9400),
])
try {
const six = buildFlowMapHops({ minutes: 5 })
assert.equal(six.totalBytes, 10_000)
const google = six.services?.find((s) => s.id === "svc:google")
assert.ok(google, "Google ≥ 5%")
assert.ok(google.share >= 0.05)
assert.ok(six.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9"))
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
googleRipe()
ingestParsedFlowsForServerForTests(7, [
payloadFlow("8.8.8.8", 400),
payloadFlow("203.0.113.50", 9600),
])
try {
const four = buildFlowMapHops({ minutes: 5 })
assert.equal(four.totalBytes, 10_000)
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
} finally {
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
disableRipeEnqueueForTests()
seedFlowTopologyForTests(topo)
rememberServerIfaces(7, [
{ ".id": "*2", name: "gre-client" },
{ ".id": "*3", name: "gre-jh-en" },
])
ingestParsedFlowsForServerForTests(7, [
{
src: "203.0.113.10",
dst: "198.51.100.1",
proto: 47,
srcPort: 0,
dstPort: 0,
bytes: 9_000,
packets: 90,
inIface: "3",
outIface: "3",
},
payloadFlow("203.0.113.50", 1000),
])
try {
const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false })
assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service")
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
resetIfaceCacheForTests()
resetRipeCacheForTests()
resetFlowCatalogForTests()
}
console.log("traffic-flow-map-hops.test.ts: ok")
+72 -1
View File
@@ -1,14 +1,22 @@
import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } 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 {
isNamedInternetService,
mapServiceNodeId,
} from "./traffic-flow-brands.js"
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.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 { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
import { loadFlowTopology, resolveEn } from "./traffic-flow-topology.js"
export const MAP_SERVICE_SHARE_THRESHOLD = 0.05
export interface FlowMapHopsQuery {
minutes: number
serverId?: number
@@ -114,6 +122,12 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
const hops = new Map<string, HopAcc>()
const svcTotals = new Map<string, { label: string; category: string; bytes: number }>()
const svcEdges = new Map<string, { fromId: string; toId: string; bytes: number; bytesFwd: number; bytesRev: number }>()
const dsts = new Set<string>()
let totalBytes = 0
refreshFlowCatalogInBackground()
for (const r of working) {
const inRes = resolveIfaceName(r.serverId, r.inIface)
@@ -199,8 +213,62 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
}, r.bytes, "fwd")
}
}
totalBytes += r.bytes
dsts.add(r.dst)
const ripe = lookupRipeCached(r.dst)
const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe)
if (isNamedInternetService(classified.service, classified.category)) {
const toId = mapServiceNodeId(classified.service)
const prevSvc = svcTotals.get(toId)
if (prevSvc) prevSvc.bytes += r.bytes
else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: r.bytes })
const svcEn = enOut ?? enIn
const svcFromId = svcEn ? String(svcEn.id) : fromId
const edgeKey = `${svcFromId}|${toId}`
const prevEdge = svcEdges.get(edgeKey)
if (prevEdge) {
prevEdge.bytes += r.bytes
prevEdge.bytesFwd += r.bytes
} else {
svcEdges.set(edgeKey, {
fromId: svcFromId,
toId,
bytes: r.bytes,
bytesFwd: r.bytes,
bytesRev: 0,
})
}
}
}
enqueueRipeMisses(dsts)
const services: FlowMapService[] = [...svcTotals.entries()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
}))
.filter((s) => s.share >= MAP_SERVICE_SHARE_THRESHOLD)
.sort((a, b) => b.bytes - a.bytes)
const keepSvc = new Set(services.map((s) => s.id))
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
.filter((e) => keepSvc.has(e.toId))
.map((e) => ({
fromId: e.fromId,
toId: e.toId,
bytes: e.bytes,
bps: (e.bytes * 8) / windowSec,
bpsFwd: (e.bytesFwd * 8) / windowSec,
bpsRev: (e.bytesRev * 8) / windowSec,
}))
.sort((a, b) => b.bytes - a.bytes)
const listener = getFlowListenerState()
return {
hops: [...hops.values()]
@@ -209,6 +277,9 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
live: listener.bound,
rangeMinutes: q.minutes,
windowSec,
totalBytes,
services,
serviceEdges,
dedupApplied: wantDedup,
excludeMeshApplied: excludeMesh,
excludeOverlayApplied: excludeOverlay,
@@ -0,0 +1,131 @@
"use client"
import type { ReactNode } from "react"
function slug(label: string): string {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
}
function GenericCloud({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
<path
d="M7.5 18h9.2A4.3 4.3 0 0 0 21 13.8a4.2 4.2 0 0 0-3.7-4.2A6.1 6.1 0 0 0 6.2 11 3.8 3.8 0 0 0 3 14.7 3.7 3.7 0 0 0 6.8 18Z"
fill="#38bdf8"
opacity="0.92"
/>
</svg>
)
}
function BrandSvg({ children, size }: { children: ReactNode; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
{children}
</svg>
)
}
export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: number }) {
switch (slug(label)) {
case "cloudflare":
return (
<BrandSvg size={size}>
<path d="M6.2 15.4h12.4c1.6 0 2.6-1.1 2.4-2.4-.2-1.4-1.4-2.1-2.8-2.1-.3-2.4-2.3-4.1-4.8-4.1-1.9 0-3.5 1-4.4 2.5-.4-.2-.9-.3-1.4-.3-1.7 0-3.1 1.3-3.2 3-.1 1.8 1.3 3.4 3.2 3.4Z" fill="#F38020" />
</BrandSvg>
)
case "google":
return (
<BrandSvg size={size}>
<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 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 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 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" />
</BrandSvg>
)
case "aws":
case "amazon":
return (
<BrandSvg size={size}>
<path d="M6.2 8.2 12 5.4l5.8 2.8v3.4L12 14.6 6.2 11.6Z" fill="#232F3E" />
<path d="M5.2 15.6c3.6 2.6 9.8 2.7 13.6 0" fill="none" stroke="#FF9900" strokeWidth="1.8" strokeLinecap="round" />
</BrandSvg>
)
case "steam":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#1b2838" />
<circle cx="8.2" cy="14.4" r="3.1" fill="#66c0f4" />
<circle cx="15.4" cy="9.2" r="3.6" fill="#c7d5e0" />
<circle cx="15.4" cy="9.2" r="1.5" fill="#1b2838" />
</BrandSvg>
)
case "blizzard":
return (
<BrandSvg size={size}>
<path d="M6 5h7.4c3 0 4.8 1.6 4.8 4.1 0 1.8-1 3.1-2.6 3.7 2 .5 3.2 2 3.2 4.1 0 2.8-2.1 4.6-5.6 4.6H6Z" fill="#00AEFF" />
<path d="M9.2 8.2h3.4c1.2 0 1.8.6 1.8 1.5s-.6 1.5-1.8 1.5H9.2Zm0 5.2h3.8c1.3 0 2 .6 2 1.6s-.7 1.6-2 1.6H9.2Z" fill="#06121f" />
</BrandSvg>
)
case "youtube":
return (
<BrandSvg size={size}>
<rect x="2" y="6" width="20" height="12" rx="3" fill="#FF0000" />
<path d="M10.2 9.2v5.6L15.6 12Z" fill="#fff" />
</BrandSvg>
)
case "netflix":
return (
<BrandSvg size={size}>
<path d="M6 3h3.2l5.6 18H11.6Z" fill="#E50914" />
<path d="M14.8 3H18v18h-3.2Z" fill="#B81D24" />
</BrandSvg>
)
case "microsoft":
return (
<BrandSvg size={size}>
<rect x="3" y="3" width="8" height="8" fill="#F25022" />
<rect x="13" y="3" width="8" height="8" fill="#7FBA00" />
<rect x="3" y="13" width="8" height="8" fill="#00A4EF" />
<rect x="13" y="13" width="8" height="8" fill="#FFB900" />
</BrandSvg>
)
case "meta":
return (
<BrandSvg size={size}>
<path d="M4 14.5c1.8-4.2 4-7.5 6.4-7.5 1.6 0 2.5 1.3 4.6 6.3 1.4 3.4 2.2 4.7 3.4 4.7 1.8 0 3.6-2.6 4.6-5" fill="none" stroke="#0081FB" strokeWidth="2.2" strokeLinecap="round" />
</BrandSvg>
)
case "telegram":
return (
<BrandSvg size={size}>
<circle cx="12" cy="12" r="10" fill="#229ED9" />
<path d="M7.2 12.1 16.8 8.4 15 16.2l-3.1-1.8-1.6 1.6-.2-2.6Z" fill="#fff" />
</BrandSvg>
)
case "discord":
return (
<BrandSvg size={size}>
<path d="M7.2 5.8 8.6 4.6c2.1.8 4.2 1.2 6.4 1.2h.8L17 5.8c1.8 2.4 2.6 5.4 2.4 8.6-1.6 1.2-3.3 2.1-5.2 2.6L13 15.2c.7-.2 1.3-.6 1.8-1.1-2 .9-4.2.9-6.2 0 .5.5 1.1.9 1.8 1.1L8.8 17c-1.9-.5-3.6-1.4-5.2-2.6C3.4 11.2 4.2 8.2 6 5.8Z" fill="#5865F2" />
<circle cx="9.2" cy="11.2" r="1.2" fill="#fff" />
<circle cx="14.8" cy="11.2" r="1.2" fill="#fff" />
</BrandSvg>
)
case "twitch":
return (
<BrandSvg size={size}>
<path d="M5 4h14v10.2l-4 4H11l-2.2 2.2H7.2V18.2H5Z" fill="#9146FF" />
<path d="M7.4 6.4h1.8v5.2H7.4Zm4 0h1.8v5.2H11.4Z" fill="#fff" />
</BrandSvg>
)
case "tiktok":
return (
<BrandSvg size={size}>
<path d="M14.2 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H14.2Z" fill="#25F4EE" />
<path d="M13.4 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H13.4Z" fill="#FE2C55" transform="translate(1.2 1)" />
</BrandSvg>
)
default:
return <GenericCloud size={size} />
}
}
+28 -3
View File
@@ -35,9 +35,10 @@ export function greTunnelProbe(t: GreTunnel): TunnelProbe {
}
}
const W = 1060
const W = 1240
const H = 580
const MARGIN = 72
const SERVICE_COL_W = 150
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
export const NETWORK_MAP_PIPELINE_Y = 300
@@ -68,7 +69,9 @@ function layerOfServer(s: Server): number | null {
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
*/
export const NETWORK_MAP_LAYOUT_REVISION = 6
export const NETWORK_MAP_W = W
export const NETWORK_MAP_H = H
export const NETWORK_MAP_LAYOUT_REVISION = 7
export interface WanJhEdge {
homeId: string
@@ -254,7 +257,7 @@ export function computeNetworkMapLayout(
const nodePos: Record<string, { x: number; y: number }> = {}
const wanSatPos: Record<string, { x: number; y: number }[]> = {}
const span = W - 2 * MARGIN
const span = W - 2 * MARGIN - SERVICE_COL_W
const laneGap = Math.min(44, span * 0.04)
const laneW = (span - 2 * laneGap) / 3
@@ -425,6 +428,28 @@ export function computeNetworkMapLayout(
return { nodePos, wanSatPos }
}
/** Колонка конечных сервисов справа от EN. */
export function placeServiceNodes(
serviceIds: string[],
enPositions: Array<{ x: number; y: number }>,
): Record<string, { x: number; y: number }> {
const out: Record<string, { x: number; y: number }> = {}
if (serviceIds.length === 0) return out
const minY = MARGIN + 70
const maxY = H - 72
const x = W - MARGIN - SERVICE_COL_W / 2
const enYs = enPositions.map((p) => p.y).filter((y) => Number.isFinite(y))
const centerY = enYs.length ? enYs.reduce((a, b) => a + b, 0) / enYs.length : (minY + maxY) / 2
const n = serviceIds.length
const gap = Math.min(96, (maxY - minY) / Math.max(1, n))
const span = gap * (n - 1)
const start = clamp(centerY - span / 2, minY, maxY - span)
serviceIds.forEach((id, i) => {
out[id] = { x, y: n === 1 ? clamp(centerY, minY, maxY) : start + i * gap }
})
return out
}
/**
* Суммарная задержка «дом JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
* Отдельного ICMP по ребру нет это не замер линии, а сумма каталожных latency концов.
+23
View File
@@ -263,11 +263,32 @@ export const flowMapHopDtoSchema = z.object({
bpsRev: z.number().nonnegative(),
})
export const flowMapServiceDtoSchema = z.object({
id: z.string(),
label: z.string(),
category: z.string(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
share: z.number().min(0).max(1),
})
export const flowMapServiceEdgeDtoSchema = z.object({
fromId: z.string(),
toId: z.string(),
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(),
totalBytes: z.number().nonnegative().optional(),
services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
dedupApplied: z.boolean(),
excludeMeshApplied: z.boolean(),
excludeOverlayApplied: z.boolean(),
@@ -287,4 +308,6 @@ 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 FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
export type FlowMapServiceEdge = z.infer<typeof flowMapServiceEdgeDtoSchema>
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>