Compare commits

...
1 Commits
Author SHA1 Message Date
DenozordecandCursor 5188b2aff2 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 <cursoragent@cursor.com>
2026-09-07 17:39:18 +07:00
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]
@@ -401,6 +401,11 @@ try {
assert.equal(googleEdge.fromId, "9", "единственный EN, даже без nextHop")
assert.ok(googleEdge.bps > 0, "скорость на hop EN→сервис")
assert.ok(!(wanOnly.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH")
const googlePath = wanOnly.servicePaths?.find((p) => p.serviceId === "svc:google")
assert.ok(googlePath, "путь WAN Google")
assert.equal(googlePath.viaId, "7", "via = JH exporter")
assert.equal(googlePath.enId, "9", "якорь EN")
assert.ok(googlePath.bps > 0, "скорость на пути клиента")
} finally {
seedFlowTopologyForTests(null)
resetFlowRingsForTests()
+84 -7
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
import { db } from "../db/index.js"
import { servers, userInterfaceBindings } from "../db/schema.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
@@ -45,9 +45,14 @@ interface HopAcc {
bytesRev: number
}
interface ClientAcc {
name: string
bytes: number
}
interface FromAcc {
bytes: number
clients: Map<string, string>
clients: Map<string, ClientAcc>
}
interface DstAcc {
@@ -58,15 +63,26 @@ interface DstAcc {
fromBytes: Map<string, FromAcc>
}
function bumpClient(clients: Map<string, ClientAcc>, bytes: number, client: { userId: string; name: string } | null): void {
const id = client?.userId || "—"
const name = client?.name || "—"
const prev = clients.get(id)
if (prev) {
prev.bytes += bytes
return
}
clients.set(id, { name, bytes })
}
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)
bumpClient(prev.clients, bytes, client)
return
}
const clients = new Map<string, string>()
if (client) clients.set(client.userId, client.name)
const clients = new Map<string, ClientAcc>()
bumpClient(clients, bytes, client)
acc.fromBytes.set(exporterId, { bytes, clients })
}
@@ -326,6 +342,16 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
bytesRev: number
clients: Map<string, string>
}>()
const svcPaths = new Map<string, {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
bytes: number
}>()
for (const h of hops.values()) {
if (h.kind !== "gre" || !h.toId) continue
@@ -346,6 +372,17 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
return null
}
function nodeName(id: string): string {
const n = Number(id)
if (Number.isFinite(n)) {
const fromDb = nameById.get(n)
if (fromDb) return fromDb
}
const en = topo.enNodes.find((node) => String(node.id) === id)
if (en?.name) return en.name
return id
}
for (const [dst, acc] of dstAcc) {
const ripe = lookupRipeCached(dst)
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
@@ -359,10 +396,14 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
if (!fromId) continue
const edgeKey = `${fromId}|${toId}`
const prevEdge = svcEdges.get(edgeKey)
const namedClients = new Map<string, string>()
for (const [id, c] of from.clients) {
if (id !== "—") namedClients.set(id, c.name)
}
if (prevEdge) {
prevEdge.bytes += from.bytes
prevEdge.bytesFwd += from.bytes
for (const [id, name] of from.clients) prevEdge.clients.set(id, name)
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
} else {
svcEdges.set(edgeKey, {
fromId,
@@ -370,9 +411,29 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
bytes: from.bytes,
bytesFwd: from.bytes,
bytesRev: 0,
clients: new Map(from.clients),
clients: namedClients,
})
}
const enName = nodeName(fromId)
const viaName = nodeName(exporterId)
for (const [clientId, c] of from.clients) {
const pathKey = `${clientId}|${exporterId}|${fromId}|${toId}`
const prevPath = svcPaths.get(pathKey)
if (prevPath) {
prevPath.bytes += c.bytes
} else {
svcPaths.set(pathKey, {
clientId,
clientName: c.name,
viaId: exporterId,
viaName,
enId: fromId,
enName,
serviceId: toId,
bytes: c.bytes,
})
}
}
}
}
@@ -410,6 +471,21 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
})
.sort((a, b) => b.bytes - a.bytes)
const servicePaths: FlowMapServicePath[] = [...svcPaths.values()]
.filter((p) => keepSvc.has(p.serviceId))
.map((p) => ({
clientId: p.clientId,
clientName: p.clientName,
viaId: p.viaId,
viaName: p.viaName,
enId: p.enId,
enName: p.enName,
serviceId: p.serviceId,
bytes: p.bytes,
bps: (p.bytes * 8) / windowSec,
}))
.sort((a, b) => b.bps - a.bps)
const listener = getFlowListenerState()
return {
hops: [...hops.values()]
@@ -421,6 +497,7 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
totalBytes,
services,
serviceEdges,
servicePaths,
mapServiceMinSharePct: minSharePct,
dedupApplied: wantDedup,
excludeMeshApplied: excludeMesh,
+43
View File
@@ -40,6 +40,10 @@ const H = 580
const MARGIN = 72
const SERVICE_COL_W = 150
/** Карточка конечного сервиса на карте (центр = позиция узла). */
export const MAP_SERVICE_NODE_W = 86
export const MAP_SERVICE_NODE_H = 58
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
export const NETWORK_MAP_PIPELINE_Y = 300
@@ -828,3 +832,42 @@ export function buildGreMapEdges(
}
return out
}
/**
* Обрезать отрезок центр круга центр прямоугольника по ободу круга и AABB карточки.
* Пунктир ENсервис визуально упирается в край, как GRE под кругами узлов.
*/
export function clipSegmentCircleToRect(
x1: number,
y1: number,
r: number,
x2: number,
y2: number,
hw: number,
hh: number,
pad = 1.5,
): { x1: number; y1: number; x2: number; y2: number } {
const dx = x2 - x1
const dy = y2 - y1
const len = Math.hypot(dx, dy)
if (len < 1e-6) return { x1, y1, x2, y2 }
const ux = dx / len
const uy = dy / len
const sx = x1 + ux * (r + pad)
const sy = y1 + uy * (r + pad)
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
const u = Math.min(
absDx < 1e-9 ? 1 : (hw + pad) / absDx,
absDy < 1e-9 ? 1 : (hh + pad) / absDy,
)
const uu = Math.min(Math.max(u, 0), 0.48)
const ex = x2 - dx * uu
const ey = y2 - dy * uu
if ((ex - sx) * dx + (ey - sy) * dy <= 0) {
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2
return { x1: mx - ux * 2, y1: my - uy * 2, x2: mx + ux * 2, y2: my + uy * 2 }
}
return { x1: sx, y1: sy, x2: ex, y2: ey }
}
+14
View File
@@ -289,6 +289,18 @@ export const flowMapServiceEdgeDtoSchema = z.object({
})).optional(),
})
export const flowMapServicePathDtoSchema = z.object({
clientId: z.string(),
clientName: z.string(),
viaId: z.string(),
viaName: z.string(),
enId: z.string(),
enName: z.string(),
serviceId: z.string(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
})
export const flowMapHopsDtoSchema = z.object({
hops: z.array(flowMapHopDtoSchema),
live: z.boolean(),
@@ -297,6 +309,7 @@ export const flowMapHopsDtoSchema = z.object({
totalBytes: z.number().nonnegative().optional(),
services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
servicePaths: z.array(flowMapServicePathDtoSchema).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
dedupApplied: z.boolean(),
excludeMeshApplied: z.boolean(),
@@ -319,4 +332,5 @@ 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 FlowMapServicePath = z.infer<typeof flowMapServicePathDtoSchema>
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>