fix(network-map): обрезать пунктир до сервиса и показать пути клиентов
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m58s
Docker images / frontend-image (push) Successful in 3m17s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 49s
Docker images / publish-release (push) Successful in 13s

Линия от обода EN до рамки сервиса; таблица клиент, узел и сервис с подсветкой на карте.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-07 17:39:18 +07:00
co-authored by Cursor
parent cd1fd2c9d3
commit 5188b2aff2
5 changed files with 339 additions and 41 deletions
+193 -34
View File
@@ -16,7 +16,10 @@ import {
buildGreMapEdges,
buildServerResourceMap,
buildWanJhEdges,
clipSegmentCircleToRect,
computeNetworkMapLayout,
MAP_SERVICE_NODE_H,
MAP_SERVICE_NODE_W,
NETWORK_MAP_H,
NETWORK_MAP_LAYOUT_REVISION,
NETWORK_MAP_PIPELINE_Y,
@@ -52,7 +55,7 @@ import {
matchNetflowForWan,
type MatchedNetflowHop,
} from "@/lib/map-netflow-hops"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/status-badge"
@@ -289,6 +292,23 @@ const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
]
const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:google", bytes: 14_000_000, bps: 5_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:google", bytes: 8_000_000, bps: 3_200_000 },
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:aws", bytes: 9_000_000, bps: 3_600_000 },
]
function servicePathKey(p: Pick<FlowMapServicePath, "clientId" | "viaId" | "enId" | "serviceId">): string {
return `${p.clientId}|${p.viaId}|${p.enId}|${p.serviceId}`
}
function greMatchesPath(e: GreMapEdge, h: { viaId: string; enId: string }): boolean {
const ids = new Set([e.fromServer.id, e.toServer.id])
return ids.has(h.viaId) && ids.has(h.enId)
}
function serviceSharePct(share: number): string {
return `${Math.round(share * 100)}%`
}
@@ -730,8 +750,8 @@ function ServiceNode({
onClick: () => void
onMouseDown: (e: React.MouseEvent) => void
}) {
const bw = 86
const bh = 58
const bw = MAP_SERVICE_NODE_W
const bh = MAP_SERVICE_NODE_H
return (
<g
transform={`translate(${x},${y})`}
@@ -777,6 +797,59 @@ function ServiceNode({
)
}
function ServicePathList({
paths,
servers,
services,
highlight,
viaMode,
onToggle,
}: {
paths: FlowMapServicePath[]
servers: Server[]
services: FlowMapService[]
highlight: { viaId: string; enId: string; serviceId: string } | null
viaMode: "via" | "service"
onToggle: (p: FlowMapServicePath) => void
}) {
if (paths.length === 0) {
return <p className="text-xs text-muted-foreground"></p>
}
return (
<div className="flex flex-col gap-1">
{paths.map((p) => {
const rowKey = servicePathKey(p)
const via = servers.find((s) => s.id === p.viaId)
const viaLabel = via?.site || p.viaName
const svc = services.find((s) => s.id === p.serviceId)
const mid = viaMode === "via" ? viaLabel : (svc?.label ?? p.serviceId)
const active = Boolean(
highlight
&& highlight.viaId === p.viaId
&& highlight.enId === p.enId
&& highlight.serviceId === p.serviceId,
)
return (
<button
key={rowKey}
type="button"
onClick={() => onToggle(p)}
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
active ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50",
)}
>
<span className="font-mono truncate min-w-0">{p.clientName} · {mid}</span>
<span className="font-mono text-emerald-400 tabular-nums shrink-0">
{formatNetflowRate({ bytes: p.bytes, bps: p.bps, bpsFwd: p.bps, bpsRev: 0 })}
</span>
</button>
)
})}
</div>
)
}
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
x: number; y: number
wan: { name: string; isp: string; maxDl: number; maxUl: number }
@@ -1017,6 +1090,7 @@ export default function NetworkMapPage() {
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([])
const [mapSharePct, setMapSharePct] = useState(5)
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
@@ -1112,6 +1186,7 @@ export default function NetworkMapPage() {
setMapHops([])
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapSharePct(5)
setDataError(null)
})
@@ -1141,6 +1216,7 @@ export default function NetworkMapPage() {
// ── Interaction ─────────────────────────────────────────────────────────────
const [selected, setSelected] = useState<Server | null>(null)
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)
@@ -1189,6 +1265,7 @@ export default function NetworkMapPage() {
setMapHops([])
setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapSharePct(5)
})
return
@@ -1198,6 +1275,7 @@ export default function NetworkMapPage() {
setMapHops([])
setMapServices([])
setMapServiceEdges([])
setMapServicePaths([])
})
return
}
@@ -1212,6 +1290,7 @@ export default function NetworkMapPage() {
setMapHops(res.hops ?? [])
setMapServices(res.services ?? [])
setMapServiceEdges(res.serviceEdges ?? [])
setMapServicePaths(res.servicePaths ?? [])
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
})
.catch((err: unknown) => {
@@ -1519,7 +1598,13 @@ export default function NetworkMapPage() {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
if (e.key === "Escape") {
setSelected(null)
setSelWanIdx(null)
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
}
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
if (e.key === "-") applyZoomCenter(1 / 1.25)
if (e.key === "0" || e.key.toLowerCase() === "f") fitView()
@@ -1610,7 +1695,13 @@ export default function NetworkMapPage() {
const moved = dragRef.current?.moved ?? false
dragRef.current = null
setIsDragging(false)
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
if (!moved) {
setSelected(null)
setSelWanIdx(null)
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
}
}
// ── Node drag start ──────────────────────────────────────────────────────
@@ -1647,6 +1738,7 @@ export default function NetworkMapPage() {
function selectServer(s: Server) {
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
setSelected(prev => prev?.id === s.id ? null : s)
setSelWanIdx(null)
setHoveredId(null)
@@ -1655,15 +1747,28 @@ export default function NetworkMapPage() {
setSelectedGreEdge(null)
setSelected(null)
setSelWanIdx(null)
setHighlightedPath(null)
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
}
function selectWan(s: Server, wanIdx: number) {
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
setSelected(s)
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
}
function togglePathHighlight(p: FlowMapServicePath) {
setHighlightedPath((prev) => (
prev
&& prev.viaId === p.viaId
&& prev.enId === p.enId
&& prev.serviceId === p.serviceId
? null
: { viaId: p.viaId, enId: p.enId, serviceId: p.serviceId }
))
}
function openWanJhSpeedDetail(ev: React.MouseEvent<SVGElement>, edge: WanJhEdge) {
ev.stopPropagation()
const home = mapServers.find((s) => s.id === edge.homeId)
@@ -1958,13 +2063,23 @@ export default function NetworkMapPage() {
setSelectedGreEdge(e)
setSelected(null)
setSelectedService(null)
setHighlightedPath(null)
setSelWanIdx(null)
}
return (
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
<g
key={edgeId}
opacity={dimmed ? 0.05 : (highlightedPath && !greMatchesPath(e, highlightedPath) ? 0.14 : 1)}
style={{ transition: "opacity 0.3s" }}
>
<line
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
stroke={ts.stroke} strokeWidth={hopHasRate(flowHop) ? 2.6 : 1.5}
stroke={ts.stroke}
strokeWidth={
highlightedPath && greMatchesPath(e, highlightedPath)
? 3.4
: hopHasRate(flowHop) ? 2.6 : 1.5
}
strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
opacity={ts.opacity}
/>
@@ -2177,27 +2292,61 @@ export default function NetworkMapPage() {
bpsFwd: edge.bpsFwd,
bpsRev: edge.bpsRev,
}
const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16)
const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId
const fromR = "type" in from && from.type
? TYPE_STYLE[from.type].r
: TYPE_STYLE["exit-node"].r
const clipped = clipSegmentCircleToRect(
from.x,
from.y,
fromR,
to.x,
to.y,
MAP_SERVICE_NODE_W / 2,
MAP_SERVICE_NODE_H / 2,
)
const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16)
const pathHit = Boolean(
highlightedPath
&& highlightedPath.enId === edge.fromId
&& highlightedPath.serviceId === edge.toId,
)
const pathDim = Boolean(highlightedPath) && !pathHit
const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId))
const svc = visibleMapServices.find((s) => s.id === edge.toId)
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel}${enName}${svc?.label ?? edge.toId}`
return (
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
<g
key={`${edge.fromId}|${edge.toId}`}
opacity={pathDim ? 0.12 : hl ? 1 : 0.72}
style={{ transition: "opacity 0.3s" }}
>
<title>{pathTitle}</title>
<line
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="#22d3ee"
strokeWidth={hopHasRate(hop) ? 2.4 : 1.4}
strokeWidth={pathHit ? 3.2 : hopHasRate(hop) ? 2.4 : 1.4}
strokeDasharray="6 5"
opacity="0.85"
pointerEvents="none"
/>
<line
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="#00000000"
strokeWidth={14}
strokeLinecap="round"
style={{ cursor: "pointer" }}
onPointerDown={(ev) => { ev.stopPropagation() }}
onClick={(ev) => {
ev.stopPropagation()
if (svc) selectService(svc)
}}
/>
{showAnimDots && hopHasRate(hop) && (
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
<animateMotion dur="2.6s" repeatCount="indefinite"
path={`M ${from.x} ${from.y} L ${to.x} ${to.y}`} />
path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} />
</circle>
)}
{hopHasRate(hop) && (
@@ -2207,8 +2356,8 @@ export default function NetworkMapPage() {
hop={hop}
onOpen={(ev) => {
ev.stopPropagation()
const svc = visibleMapServices.find((s) => s.id === edge.toId)
if (svc) selectService(svc)
const hit = visibleMapServices.find((s) => s.id === edge.toId)
if (hit) selectService(hit)
}}
/>
)}
@@ -2608,25 +2757,18 @@ export default function NetworkMapPage() {
</div>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Клиенты</p>
<div className="flex flex-col gap-1.5">
{(() => {
const names = new Map<string, string>()
for (const e of visibleServiceEdges.filter((x) => x.toId === selectedService.id)) {
if (e.clients?.length) {
for (const c of e.clients) names.set(c.id, c.name)
} else if (e.clientName) {
names.set(e.clientId ?? e.clientName, e.clientName)
}
}
if (names.size === 0) {
return <p className="text-xs text-muted-foreground"></p>
}
return [...names.values()].map((name) => (
<p key={name} className="text-xs font-mono truncate">{name}</p>
))
})()}
</div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
<ServicePathList
paths={mapServicePaths
.filter((p) => p.serviceId === selectedService.id)
.slice()
.sort((a, b) => b.bps - a.bps)}
servers={mapServers}
services={mapServices}
highlight={highlightedPath}
viaMode="via"
onToggle={togglePathHighlight}
/>
</div>
</div>
</>
@@ -2855,6 +2997,23 @@ export default function NetworkMapPage() {
</div>
)}
{(selected.type === "jump-host" || selected.type === "exit-node") && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
<ServicePathList
paths={mapServicePaths
.filter((p) => p.viaId === selected.id || p.enId === selected.id)
.slice()
.sort((a, b) => b.bps - a.bps)}
servers={mapServers}
services={mapServices}
highlight={highlightedPath}
viaMode="service"
onToggle={togglePathHighlight}
/>
</div>
)}
{/* Resources */}
{(() => {
const res = srvResMap[selected.id]