Skip to content

Commit 3217f76

Browse files
heanlancursoragent
andcommitted
Improve service map Connection Stats panel UX
- Dismiss the hover tooltip and the click-selected edge stats panel when filters are applied, reset, or cleared so stale data from the previous filter is not shown alongside new results. - Move the close (✕) button out of the title row into an absolute-positioned icon in the top-right corner of the panel so the title row only contains "Connection Stats". - Keep the panel stats live after click by storing only the selected edge key in state and re-deriving EdgeDetails from the live graph.edgeMap on every render, so bytes, bit rate, and all other fields update continuously alongside incoming flow records. - Add namespace clustering force so same-namespace nodes stay grouped instead of drifting far apart; tune link distance and repulsion to produce a more compact layout. - Fix service map SVG to fill the full container width using a ResizeObserver on the container element; include svgWidth in the topology key so the simulation recenters on resize. - Pin nodes after drag so user-placed positions are preserved; double-click a node to release the pin. - Fix stale-closure bug where edge hover tooltip and click panel read byte/connection stats from the graph captured at topology-build time rather than the latest graph; introduce graphRef to always reflect the most recent flow data. Signed-off-by: Anlan He <anlan.he@broadcom.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8286e07 commit 3217f76

2 files changed

Lines changed: 120 additions & 35 deletions

File tree

client/web/antrea-ui/src/routes/flowvisibility.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ export default function FlowVisibility() {
5151
}, [clearFlows]);
5252

5353
return (
54-
<main style={{ maxWidth: '100%', overflowX: 'auto' }}>
55-
<div cds-layout="vertical gap:lg" style={{ maxWidth: '100%' }}>
54+
<main style={{ width: '100%', maxWidth: '100%', overflowX: 'auto' }}>
55+
<div cds-layout="vertical gap:lg" style={{ width: '100%', maxWidth: '100%' }}>
5656
<div cds-layout="horizontal gap:md align:vertical-center">
5757
<p cds-text="title">Flow Visibility</p>
5858
<div cds-layout="horizontal gap:xs">

client/web/antrea-ui/src/routes/servicemap.tsx

Lines changed: 118 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { useRef, useEffect, useMemo, useState, useCallback } from 'react';
17+
import { useRef, useEffect, useMemo, useState, useCallback, useLayoutEffect } from 'react';
1818
import * as d3 from 'd3';
1919
import { FlowEntry, entryBitRate } from '../store/flow-store';
2020
import {
@@ -285,8 +285,7 @@ interface D3Link extends d3.SimulationLinkDatum<D3Node> {
285285
curveOffset: number;
286286
}
287287

288-
const WIDTH = 1100;
289-
const HEIGHT = 700;
288+
const HEIGHT = 900;
290289
const NODE_RX = 8;
291290
const NODE_PADDING_X = 14;
292291
const NODE_PADDING_Y = 8;
@@ -310,20 +309,26 @@ function EdgeDetailsPanel({ details, onClose }: { details: EdgeDetails; onClose:
310309
maxWidth: '350px',
311310
zIndex: 10,
312311
}}>
313-
<div cds-layout="horizontal justify:space-between align:vertical-center" style={{ marginBottom: '12px' }}>
312+
<button
313+
onClick={onClose}
314+
title="Close"
315+
style={{
316+
position: 'absolute',
317+
top: '8px',
318+
right: '8px',
319+
background: 'none',
320+
border: 'none',
321+
color: 'var(--cds-alias-object-border-color, #565656)',
322+
cursor: 'pointer',
323+
fontSize: '14px',
324+
lineHeight: 1,
325+
padding: '2px',
326+
}}
327+
>
328+
329+
</button>
330+
<div style={{ marginBottom: '12px' }}>
314331
<span cds-text="section">Connection Stats</span>
315-
<button
316-
onClick={onClose}
317-
style={{
318-
background: 'none',
319-
border: 'none',
320-
color: 'var(--cds-global-typography-color-400, #fff)',
321-
cursor: 'pointer',
322-
fontSize: '18px',
323-
}}
324-
>
325-
326-
</button>
327332
</div>
328333
<div cds-layout="vertical gap:sm">
329334
<div><strong>Source:</strong> {getWorkloadShortName(details.source)}</div>
@@ -416,20 +421,52 @@ function nodeBoundaryPoint(d: D3Node, towardX: number, towardY: number, gap: num
416421
}
417422

418423
export default function ServiceMap({ entries }: ServiceMapProps) {
424+
const containerRef = useRef<HTMLDivElement>(null);
419425
const svgRef = useRef<SVGSVGElement>(null);
420426
const tooltipRef = useRef<HTMLDivElement>(null);
421427
const simulationRef = useRef<d3.Simulation<D3Node, D3Link> | null>(null);
422-
const [selectedEdge, setSelectedEdge] = useState<EdgeDetails | null>(null);
428+
const [selectedEdgeKey, setSelectedEdgeKey] = useState<string | null>(null);
429+
const [svgWidth, setSvgWidth] = useState(1100);
430+
431+
useLayoutEffect(() => {
432+
const el = containerRef.current;
433+
if (!el) return;
434+
// Measure a guaranteed full-width block ancestor (the page <main>) rather
435+
// than the container itself. The container lives inside a Clarity
436+
// cds-layout="vertical" flex column whose cross-axis sizing can shrink-wrap
437+
// the container to the SVG's own pixel width, which would pin svgWidth near
438+
// its default (a feedback loop). <main> is display:block and always fills
439+
// its flex:1 parent, so its clientWidth is the true available width.
440+
const measureTarget: HTMLElement = el.closest('main') ?? el.parentElement ?? el;
441+
const measure = () => {
442+
const w = measureTarget.clientWidth;
443+
if (w && w > 0) setSvgWidth(Math.floor(w));
444+
};
445+
const ro = new ResizeObserver(measure);
446+
ro.observe(measureTarget);
447+
measure();
448+
return () => ro.disconnect();
449+
}, []);
423450

424451
const graph = useMemo(() => buildGraph(entries), [entries]);
425452

453+
// Keep a ref to the latest graph so D3 event handlers (bound once at
454+
// topology-build time) always read fresh edge data without needing to be
455+
// re-registered on every flow update.
456+
const graphRef = useRef(graph);
457+
graphRef.current = graph;
458+
459+
const selectedEdge = selectedEdgeKey !== null
460+
? (graph.edgeMap.has(selectedEdgeKey) ? edgeToDetails(graph.edgeMap.get(selectedEdgeKey)!) : null)
461+
: null;
462+
426463
const prevTopologyRef = useRef<string>('');
427464

428465
const topologyKey = useMemo(() => {
429466
const nodeIds = graph.nodes.map(n => n.id).sort().join(',');
430467
const edgeIds = graph.edges.map(e => `${e.source}|${e.target}`).sort().join(',');
431-
return `${nodeIds}::${edgeIds}`;
432-
}, [graph]);
468+
return `${nodeIds}::${edgeIds}::${svgWidth}`;
469+
}, [graph, svgWidth]);
433470

434471
const topologyChanged = topologyKey !== prevTopologyRef.current;
435472

@@ -482,6 +519,16 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
482519
if (tip) tip.style.opacity = '0';
483520
}, []);
484521

522+
// Dismiss the hover tooltip and the click-selected edge panel whenever the
523+
// flow store is cleared (filter applied/reset/clear), so stale data from the
524+
// previous filter is not shown alongside the new results.
525+
useEffect(() => {
526+
if (entries.length === 0) {
527+
hideTooltip();
528+
setSelectedEdgeKey(null);
529+
}
530+
}, [entries.length, hideTooltip]);
531+
485532
useEffect(() => {
486533
if (!svgRef.current) return;
487534

@@ -593,7 +640,7 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
593640
d3.select(this)
594641
.attr('stroke-opacity', 1)
595642
.attr('stroke-width', Math.min(1.5 + Math.log2(d.connectionCount + 1), 6) + 1.5);
596-
const edgeData = graph.edgeMap.get(d.edgeKey);
643+
const edgeData = graphRef.current.edgeMap.get(d.edgeKey);
597644
if (edgeData) showTooltip(event, edgeData);
598645
})
599646
.on('mousemove', function (event) {
@@ -610,8 +657,8 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
610657
hideTooltip();
611658
})
612659
.on('click', (_event, d) => {
613-
const edgeData = graph.edgeMap.get(d.edgeKey);
614-
if (edgeData) setSelectedEdge(edgeToDetails(edgeData));
660+
const edgeData = graphRef.current.edgeMap.get(d.edgeKey);
661+
if (edgeData) setSelectedEdgeKey(d.edgeKey);
615662
});
616663

617664
const edgeLabelGroup = container.append('g');
@@ -653,10 +700,18 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
653700
})
654701
.on('end', (event, d) => {
655702
if (!event.active) simulation.alphaTarget(0);
656-
d.fx = null;
657-
d.fy = null;
703+
// Keep fx/fy set so the node stays where the user placed it.
704+
// Double-click releases the pin.
705+
d.fx = d.x;
706+
d.fy = d.y;
658707
})
659-
);
708+
)
709+
.on('dblclick', (_event, d) => {
710+
d.fx = null;
711+
d.fy = null;
712+
simulation.alphaTarget(0.3).restart();
713+
setTimeout(() => simulation.alphaTarget(0), 1500);
714+
});
660715

661716
node.each(function (d) {
662717
const g = d3.select(this);
@@ -746,11 +801,40 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
746801
return Math.max(nameW, nsW) / 2 + NODE_PADDING_X + 4;
747802
}
748803

804+
// Custom force: pull same-namespace nodes toward their namespace centroid.
805+
function forceNamespaceClustering(alpha: number) {
806+
const centroids = new Map<string, { x: number; y: number; count: number }>();
807+
for (const n of d3Nodes) {
808+
if (!n.namespace || n.isExternal) continue;
809+
const c = centroids.get(n.namespace);
810+
if (c) {
811+
c.x += n.x!;
812+
c.y += n.y!;
813+
c.count++;
814+
} else {
815+
centroids.set(n.namespace, { x: n.x!, y: n.y!, count: 1 });
816+
}
817+
}
818+
for (const [ns, c] of centroids) {
819+
c.x /= c.count;
820+
c.y /= c.count;
821+
centroids.set(ns, c);
822+
}
823+
const strength = 0.15 * alpha;
824+
for (const n of d3Nodes) {
825+
if (!n.namespace || n.isExternal) continue;
826+
const c = centroids.get(n.namespace)!;
827+
n.vx! += (c.x - n.x!) * strength;
828+
n.vy! += (c.y - n.y!) * strength;
829+
}
830+
}
831+
749832
const simulation = d3.forceSimulation(d3Nodes)
750-
.force('link', d3.forceLink<D3Node, D3Link>(d3Links).id(d => d.id).distance(220))
751-
.force('charge', d3.forceManyBody().strength(-800))
752-
.force('center', d3.forceCenter(WIDTH / 2, HEIGHT / 2))
753-
.force('collision', d3.forceCollide<D3Node>().radius(d => nodeRadius(d) + 30))
833+
.force('link', d3.forceLink<D3Node, D3Link>(d3Links).id(d => d.id).distance(120))
834+
.force('charge', d3.forceManyBody().strength(-400))
835+
.force('center', d3.forceCenter(svgWidth / 2, HEIGHT / 2))
836+
.force('collision', d3.forceCollide<D3Node>().radius(d => nodeRadius(d) + 20))
837+
.force('cluster', forceNamespaceClustering as unknown as d3.Force<D3Node, D3Link>)
754838
.on('tick', () => {
755839
linkPaths.attr('d', d => {
756840
const s = d.source as D3Node;
@@ -798,23 +882,24 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
798882
return edgeData ? Math.min(1.5 + Math.log2(edgeData.connectionCount + 1), 6) : 1;
799883
});
800884
}
801-
}, [graph, topologyChanged, topologyKey, showTooltip, hideTooltip]);
885+
}, [graph, topologyChanged, topologyKey, showTooltip, hideTooltip, svgWidth]);
802886

803887
useEffect(() => {
804888
return () => {
805889
simulationRef.current?.stop();
806890
};
807891
}, []);
808892

809-
const handleCloseDetails = useCallback(() => setSelectedEdge(null), []);
893+
const handleCloseDetails = useCallback(() => setSelectedEdgeKey(null), []);
810894

811895
return (
812-
<div style={{ position: 'relative' }}>
896+
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
813897
<svg
814898
ref={svgRef}
815-
width={WIDTH}
899+
width={svgWidth}
816900
height={HEIGHT}
817901
style={{
902+
display: 'block',
818903
border: '1px solid var(--cds-alias-object-border-color, #565656)',
819904
borderRadius: '4px',
820905
background: 'var(--cds-alias-object-container-background-dark, #17242b)',

0 commit comments

Comments
 (0)