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
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]>
This commit is contained in:
@@ -282,11 +282,11 @@ const MOCK_MAP_SERVICES: FlowMapService[] = [
|
||||
]
|
||||
|
||||
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 },
|
||||
{ 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, 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, 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, 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, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
|
||||
]
|
||||
|
||||
function serviceSharePct(share: number): string {
|
||||
@@ -637,7 +637,9 @@ function ServiceNode({
|
||||
y,
|
||||
isSel,
|
||||
isVis,
|
||||
isDragged,
|
||||
onClick,
|
||||
onMouseDown,
|
||||
}: {
|
||||
label: string
|
||||
share: number
|
||||
@@ -645,15 +647,18 @@ function ServiceNode({
|
||||
y: number
|
||||
isSel: boolean
|
||||
isVis: boolean
|
||||
isDragged: boolean
|
||||
onClick: () => void
|
||||
onMouseDown: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
const bw = 86
|
||||
const bh = 58
|
||||
return (
|
||||
<g
|
||||
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}
|
||||
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
|
||||
@@ -680,14 +685,9 @@ function ServiceNode({
|
||||
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>
|
||||
<g transform="translate(-11,-24)" pointerEvents="none">
|
||||
<ServiceBrandIcon label={label} size={22} />
|
||||
</g>
|
||||
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
|
||||
{label}
|
||||
</text>
|
||||
@@ -1068,6 +1068,7 @@ export default function NetworkMapPage() {
|
||||
// ── Node positions (overrides POS defaults) ─────────────────────────────────
|
||||
const [nodePositions, setNodePositions] = 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. */
|
||||
useEffect(() => {
|
||||
@@ -1079,6 +1080,7 @@ export default function NetworkMapPage() {
|
||||
// и перекрывают новый авто-лейаут на live-данных.
|
||||
setNodePositions({})
|
||||
setSatPositions({})
|
||||
setServicePositions({})
|
||||
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
|
||||
}
|
||||
} catch {
|
||||
@@ -1351,13 +1353,16 @@ export default function NetworkMapPage() {
|
||||
})
|
||||
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
|
||||
|
||||
const servicePosById = placeServiceNodes(
|
||||
const autoServicePos = 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)),
|
||||
)
|
||||
const servicePosById = Object.fromEntries(
|
||||
visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoServicePos[s.id]!]),
|
||||
)
|
||||
|
||||
// ── Refs ─────────────────────────────────────────────────────────────────────
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
@@ -1373,10 +1378,12 @@ export default function NetworkMapPage() {
|
||||
type NodeDrag =
|
||||
| { 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: "service"; svcId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
|
||||
const nodeDragRef = useRef<NodeDrag | null>(null)
|
||||
const suppressClickRef = useRef(false)
|
||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||
const [draggedSatKey, setDraggedSatKey] = useState<string | null>(null) // `${homeId}-${wanIdx}`
|
||||
const [draggedSvcId, setDraggedSvcId] = useState<string | null>(null)
|
||||
|
||||
const zoomRef = useRef(zoom)
|
||||
const panRef = useRef(pan)
|
||||
@@ -1486,6 +1493,8 @@ export default function NetworkMapPage() {
|
||||
|
||||
if (nd.kind === "server") {
|
||||
setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } }))
|
||||
} else if (nd.kind === "service") {
|
||||
setServicePositions(prev => ({ ...prev, [nd.svcId]: { x: nx, y: ny } }))
|
||||
} else {
|
||||
setSatPositions(prev => {
|
||||
const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))]
|
||||
@@ -1513,6 +1522,7 @@ export default function NetworkMapPage() {
|
||||
nodeDragRef.current = null
|
||||
setDraggedNodeId(null)
|
||||
setDraggedSatKey(null)
|
||||
setDraggedSvcId(null)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1533,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 }
|
||||
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 ──────────────────────────────────────────────────
|
||||
const sq = search.toLowerCase().trim()
|
||||
@@ -1748,10 +1762,10 @@ export default function NetworkMapPage() {
|
||||
? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow`
|
||||
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
|
||||
</p>
|
||||
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0) && (
|
||||
{(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">
|
||||
<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
|
||||
text-amber-400 hover:bg-amber-500/10 transition-colors">
|
||||
↺ Сбросить расположение
|
||||
@@ -2005,7 +2019,7 @@ export default function NetworkMapPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── EN/JH → destination services ── */}
|
||||
{/* ── EN → destination services ── */}
|
||||
{visibleServiceEdges.map((edge) => {
|
||||
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
||||
const to = servicePosById[edge.toId]
|
||||
@@ -2018,8 +2032,13 @@ export default function NetworkMapPage() {
|
||||
}
|
||||
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 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" }}>
|
||||
<title>{pathTitle}</title>
|
||||
<line
|
||||
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
|
||||
stroke="#22d3ee"
|
||||
@@ -2107,6 +2126,8 @@ export default function NetworkMapPage() {
|
||||
y={pos.y}
|
||||
isSel={selectedService?.id === svc.id}
|
||||
isVis
|
||||
isDragged={draggedSvcId === svc.id}
|
||||
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
|
||||
onClick={() => {
|
||||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||
selectService(svc)
|
||||
@@ -2491,7 +2512,7 @@ export default function NetworkMapPage() {
|
||||
</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">
|
||||
{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
|
||||
const src = mapServers.find((s) => s.id === e.fromId)
|
||||
@@ -2506,6 +2527,27 @@ 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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : selected ? (
|
||||
|
||||
Reference in New Issue
Block a user