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 <[email protected]>
This commit is contained in:
Denozordec
2026-09-07 13:30:45 +07:00
co-authored by Cursor
parent 6332d83a12
commit db64621122
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">