Add topology link aggregation and fix WebCRT clipboard.

Default logical bundles for parallel links with optional physical expand and clearer NE-named ports; capture selection on context menu so copy/paste works again.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-04 11:42:41 +08:00
parent 6e7d84b782
commit 57f45fdaaf
7 changed files with 464 additions and 52 deletions

View file

@ -217,8 +217,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve()); const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
/** Updated whenever device stdout arrives; used to pace paste by echo, not fixed sleep. */ /** Updated whenever device stdout arrives; used to pace paste by echo, not fixed sleep. */
const lastStdoutAtRef = useRef(0); const lastStdoutAtRef = useRef(0);
/** Snapshot selection when opening the context menu — click on menu clears xterm selection. */
const menuSelectionRef = useRef("");
const keywordHighlightRef = useRef(keywordHighlight); const keywordHighlightRef = useRef(keywordHighlight);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [clipStatus, setClipStatus] = useState<string | null>(null);
const [findOpen, setFindOpen] = useState(false); const [findOpen, setFindOpen] = useState(false);
const [findQuery, setFindQuery] = useState(""); const [findQuery, setFindQuery] = useState("");
const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null); const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null);
@ -635,6 +638,40 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
return; return;
} }
// CRT-style clipboard: Ctrl+Shift+C / Ctrl+Insert copy; Ctrl+Shift+V / Shift+Insert paste.
const mod = e.ctrlKey || e.metaKey;
if (mod && e.shiftKey && (e.key === "C" || e.key === "c")) {
e.preventDefault();
e.stopPropagation();
const text = term.getSelection() || "";
if (text && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text).catch(() => undefined);
}
return;
}
if ((mod && e.shiftKey && (e.key === "V" || e.key === "v")) || (e.shiftKey && e.key === "Insert")) {
e.preventDefault();
e.stopPropagation();
void (async () => {
try {
const text = await navigator.clipboard.readText();
if (text) sendStdinThrottled(text);
} catch {
/* permission denied */
}
})();
return;
}
if (mod && e.key === "Insert") {
e.preventDefault();
e.stopPropagation();
const text = term.getSelection() || "";
if (text && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text).catch(() => undefined);
}
return;
}
if (e.key === "Backspace") { if (e.key === "Backspace") {
e.preventDefault(); e.preventDefault();
} }
@ -656,6 +693,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const onContextMenu = (e: MouseEvent) => { const onContextMenu = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
menuSelectionRef.current = term.getSelection() || "";
setCtxMenu({ x: e.clientX, y: e.clientY }); setCtxMenu({ x: e.clientX, y: e.clientY });
}; };
host.addEventListener("contextmenu", onContextMenu); host.addEventListener("contextmenu", onContextMenu);
@ -723,20 +761,30 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const pasteFromClipboard = async () => { const pasteFromClipboard = async () => {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
sendStdinThrottled(text); if (text) sendStdinThrottled(text);
else setClipStatus(t("webcrt.term.pasteEmpty"));
} catch { } catch {
/* ignore */ setClipStatus(t("webcrt.term.pasteFailed"));
} }
setCtxMenu(null); setCtxMenu(null);
focusTerminal(); focusTerminal();
window.setTimeout(() => setClipStatus(null), 2200);
}; };
const copySelection = async () => { const copySelection = async () => {
const text = termRef.current?.getSelection() || ""; const text = menuSelectionRef.current || termRef.current?.getSelection() || "";
if (text && navigator.clipboard?.writeText) { try {
await navigator.clipboard.writeText(text); if (text && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
setClipStatus(t("webcrt.actions.copied"));
} else {
setClipStatus(t("webcrt.actions.copyEmpty"));
}
} catch {
setClipStatus(t("webcrt.actions.copyFailed"));
} }
setCtxMenu(null); setCtxMenu(null);
window.setTimeout(() => setClipStatus(null), 2200);
}; };
return ( return (
@ -746,6 +794,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
{t("webcrt.term.pasting", { done: pasteStatus.done, total: pasteStatus.total })} {t("webcrt.term.pasting", { done: pasteStatus.done, total: pasteStatus.total })}
</div> </div>
) : null} ) : null}
{clipStatus ? (
<div className="webcrt-paste-status" role="status" aria-live="polite">
{clipStatus}
</div>
) : null}
{findOpen ? ( {findOpen ? (
<div className="webcrt-find"> <div className="webcrt-find">
<input <input

View file

@ -1226,6 +1226,8 @@ const en = {
findClose: "Close find", findClose: "Close find",
copy: "Copy", copy: "Copy",
paste: "Paste", paste: "Paste",
pasteEmpty: "Clipboard is empty",
pasteFailed: "Cannot read clipboard (try Ctrl+Shift+V or check browser permission)",
clear: "Clear", clear: "Clear",
pasting: "Pasting {{done}}/{{total}} lines…", pasting: "Pasting {{done}}/{{total}} lines…",
wsError: "[WebSocket error]", wsError: "[WebSocket error]",
@ -1335,6 +1337,11 @@ const en = {
renameNodePrompt: "Node display name", renameNodePrompt: "Node display name",
sourcePort: "Local port", sourcePort: "Local port",
targetPort: "Remote port", targetPort: "Remote port",
endpointA: "Endpoint A",
endpointB: "Endpoint B",
port: "port",
expandPhysicalLinks: "Expand physical links",
linkMembers: "Physical members ({{count}})",
canvasHint: canvasHint:
"Left tree lists regions with a physical map and optional custom maps. Open a map to edit devices; use Add NE and save positions.", "Left tree lists regions with a physical map and optional custom maps. Open a map to edit devices; use Add NE and save positions.",
selected: "Selected", selected: "Selected",

View file

@ -1220,6 +1220,8 @@ const zh = {
findClose: "关闭查找", findClose: "关闭查找",
copy: "复制", copy: "复制",
paste: "粘贴", paste: "粘贴",
pasteEmpty: "剪贴板为空",
pasteFailed: "无法读取剪贴板(请用 Ctrl+Shift+V 或检查浏览器权限)",
clear: "清空", clear: "清空",
pasting: "粘贴中 {{done}}/{{total}} 行…", pasting: "粘贴中 {{done}}/{{total}} 行…",
wsError: "[WebSocket 错误]", wsError: "[WebSocket 错误]",
@ -1329,6 +1331,11 @@ const zh = {
renameNodePrompt: "网元显示名称", renameNodePrompt: "网元显示名称",
sourcePort: "本端端口", sourcePort: "本端端口",
targetPort: "对端端口", targetPort: "对端端口",
endpointA: "端点 A",
endpointB: "端点 B",
port: "端口",
expandPhysicalLinks: "展开物理链路",
linkMembers: "物理成员({{count}})",
canvasHint: canvasHint:
"左侧为区域目录(物理拓扑 + 自定义图);打开本图后进入设备画布,用「添加网元」上图并保存。", "左侧为区域目录(物理拓扑 + 自定义图);打开本图后进入设备画布,用「添加网元」上图并保存。",
selected: "已选节点", selected: "已选节点",

View file

@ -6728,6 +6728,37 @@ pre {
font-size: 11px; font-size: 11px;
} }
.topo-edge-label {
font-size: 11px;
color: #475569;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 1px 6px;
white-space: nowrap;
}
.topo-edge-label.is-selected {
color: #1d4ed8;
border-color: #93c5fd;
font-weight: 600;
}
.topo-ctx__member-list {
margin: 0 0 8px;
padding: 0 0 0 16px;
font-size: 12px;
color: #334155;
max-height: 120px;
overflow: auto;
}
.topo-ctx__style-members {
grid-column: 1 / -1;
display: grid;
gap: 6px;
}
@media (max-width: 960px) { @media (max-width: 960px) {
.topo-page, .topo-page,
.topo-page.is-sidebar-collapsed { .topo-page.is-sidebar-collapsed {

View file

@ -76,6 +76,15 @@ import type {
TopologyViewRole, TopologyViewRole,
} from "../types"; } from "../types";
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph"; import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
import { ParallelEdge } from "./topology/ParallelEdge";
import {
buildLinkDisplayEdges,
formatPortPairLabel,
isAggregateEdgeId,
physicalIdsForDisplayEdge,
type LinkEdgeData,
type LinkMember,
} from "./topology/linkDisplay";
import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode"; import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode";
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId"; const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
@ -435,16 +444,9 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
}); });
const nodeTypes = { neNode: NeNode }; const nodeTypes = { neNode: NeNode };
const edgeTypes = { topoParallel: ParallelEdge };
type EdgeStyleData = { type EdgeStyleData = LinkEdgeData;
source?: string;
source_port?: string;
target_port?: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
discovered_at?: string | null;
};
type EdgeLineStyle = "solid" | "dashed" | "dotted"; type EdgeLineStyle = "solid" | "dashed" | "dotted";
type EdgeSourceKind = "manual" | "discovered" | "stale"; type EdgeSourceKind = "manual" | "discovered" | "stale";
@ -603,7 +605,7 @@ function graphToFlow(
const rfEdges: Edge[] = edges.map((e) => { const rfEdges: Edge[] = edges.map((e) => {
const src = const src =
e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual"; e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual";
const label = [e.a_port, e.b_port].filter(Boolean).join(SEP); const label = formatPortPairLabel(e.a_port || "", e.b_port || "");
const data: EdgeStyleData = { const data: EdgeStyleData = {
source: src, source: src,
source_port: e.a_port || "", source_port: e.a_port || "",
@ -705,6 +707,8 @@ export function TopologyPage() {
const [hideIp, setHideIp] = useState(true); const [hideIp, setHideIp] = useState(true);
const [hideVendor, setHideVendor] = useState(true); const [hideVendor, setHideVendor] = useState(true);
const [hidePorts, setHidePorts] = useState(true); const [hidePorts, setHidePorts] = useState(true);
/** Default: logical aggregate of parallel links; expand to see each physical edge. */
const [expandPhysicalLinks, setExpandPhysicalLinks] = useState(false);
const [edgeFlow, setEdgeFlow] = useState(false); const [edgeFlow, setEdgeFlow] = useState(false);
const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults()); const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
const [toolMode, setToolMode] = useState<ToolMode>("select"); const [toolMode, setToolMode] = useState<ToolMode>("select");
@ -812,15 +816,25 @@ export function TopologyPage() {
() => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect" }), () => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect" }),
[hideIp, hideVendor, hidePorts, toolMode], [hideIp, hideVendor, hidePorts, toolMode],
); );
const displayEdges = useMemo( const displayEdges = useMemo(() => {
() => const built = buildLinkDisplayEdges(edges, expandPhysicalLinks).map((e) =>
edges.map((e) => ({ withEdgeVisual(e, edgeDefaults),
);
return built.map((e) => {
const d = (e.data || {}) as EdgeStyleData;
const count = Number(d.member_count || 1);
const keepCountLabel = !expandPhysicalLinks && count > 1;
const selected =
e.id === selectedEdgeId ||
Boolean(d.members?.some((m: LinkMember) => m.id === selectedEdgeId));
return {
...e, ...e,
label: hidePorts ? undefined : e.label, selected,
label: hidePorts && !keepCountLabel ? undefined : e.label,
animated: edgeFlow, animated: edgeFlow,
})), };
[edges, hidePorts, edgeFlow], });
); }, [edges, expandPhysicalLinks, hidePorts, edgeFlow, edgeDefaults, selectedEdgeId]);
const treeQuery = useQuery({ const treeQuery = useQuery({
queryKey: queryKeys.topologyTree, queryKey: queryKeys.topologyTree,
@ -1593,12 +1607,18 @@ export function TopologyPage() {
const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]); const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]);
const selectedNode = selectedNodes[0] || null; const selectedNode = selectedNodes[0] || null;
const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]); const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]);
const selectedEdge = useMemo( const selectedEdge = useMemo(() => {
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) || null : null), if (!selectedEdgeId) return null;
[edges, selectedEdgeId], return (
); displayEdges.find((e) => e.id === selectedEdgeId) ||
edges.find((e) => e.id === selectedEdgeId) ||
null
);
}, [selectedEdgeId, displayEdges, edges]);
const selectedEdgeData = (selectedEdge?.data || {}) as EdgeStyleData; const selectedEdgeData = (selectedEdge?.data || {}) as EdgeStyleData;
const selectedEdgeResolved = selectedEdge ? resolveEdgeStyle(selectedEdgeData, edgeDefaults) : null; const selectedEdgeResolved = selectedEdge ? resolveEdgeStyle(selectedEdgeData, edgeDefaults) : null;
const selectedEdgeSourceNode = selectedEdge ? nodes.find((n) => n.id === selectedEdge.source) : null;
const selectedEdgeTargetNode = selectedEdge ? nodes.find((n) => n.id === selectedEdge.target) : null;
const patchSelectedEdgeStyle = useCallback( const patchSelectedEdgeStyle = useCallback(
( (
@ -1608,10 +1628,20 @@ export function TopologyPage() {
opts?: { skipHistory?: boolean }, opts?: { skipHistory?: boolean },
) => { ) => {
if (!selectedEdgeId || !mapId) return; if (!selectedEdgeId || !mapId) return;
const display = displayEdges.find((e) => e.id === selectedEdgeId) || selectedEdge;
const targetIds = physicalIdsForDisplayEdge(display, edges);
if (!targetIds.length) return;
// Port edits only make sense on a single physical link.
if (
(isAggregateEdgeId(selectedEdgeId) || targetIds.length > 1) &&
(patch.source_port !== undefined || patch.target_port !== undefined)
) {
return;
}
if (!opts?.skipHistory) pushHistory(); if (!opts?.skipHistory) pushHistory();
setEdges((eds) => setEdges((eds) =>
eds.map((e) => { eds.map((e) => {
if (e.id !== selectedEdgeId) return e; if (!targetIds.includes(e.id)) return e;
const prev = (e.data || {}) as EdgeStyleData; const prev = (e.data || {}) as EdgeStyleData;
const data: EdgeStyleData = { const data: EdgeStyleData = {
...prev, ...prev,
@ -1622,21 +1652,23 @@ export function TopologyPage() {
source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "", source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "",
target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "", target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "",
}; };
const portLabel = [data.source_port, data.target_port].filter(Boolean).join(SEP); const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults); return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults);
}), }),
); );
const edge = edges.find((e) => e.id === selectedEdgeId); const edge = edges.find((e) => e.id === targetIds[0]);
const prev = (edge?.data || {}) as EdgeStyleData; const prev = (edge?.data || {}) as EdgeStyleData;
void patchTopologyEdgeStyle(mapId, { for (const fabricEdgeId of targetIds) {
fabric_edge_id: selectedEdgeId, void patchTopologyEdgeStyle(mapId, {
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "", fabric_edge_id: fabricEdgeId,
stroke_width: stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0), stroke_width:
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "", patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
}).catch((err) => showError(String(err))); line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
}).catch((err) => showError(String(err)));
}
}, },
[selectedEdgeId, mapId, edges, setEdges, pushHistory, edgeDefaults, showError], [selectedEdgeId, selectedEdge, mapId, edges, displayEdges, setEdges, pushHistory, edgeDefaults, showError],
); );
const renameSelectedNode = useCallback(() => { const renameSelectedNode = useCallback(() => {
@ -1822,8 +1854,11 @@ export function TopologyPage() {
const removeEdgeById = (edgeId: string) => { const removeEdgeById = (edgeId: string) => {
pushHistory(); pushHistory();
markDirty(); markDirty();
setEdges((es) => es.filter((e) => e.id !== edgeId)); const display = displayEdges.find((e) => e.id === edgeId);
setSelectedEdgeId((cur) => (cur === edgeId ? null : cur)); const ids = new Set(physicalIdsForDisplayEdge(display, edges));
if (!ids.size) ids.add(edgeId);
setEdges((es) => es.filter((e) => !ids.has(e.id)));
setSelectedEdgeId((cur) => (cur && (ids.has(cur) || cur === edgeId) ? null : cur));
closeCtxMenu(); closeCtxMenu();
}; };
@ -2215,7 +2250,8 @@ export function TopologyPage() {
(edgeId: string) => { (edgeId: string) => {
setSelectedEdgeId(edgeId); setSelectedEdgeId(edgeId);
setNodes((ns) => ns.map((n) => ({ ...n, selected: false }))); setNodes((ns) => ns.map((n) => ({ ...n, selected: false })));
setEdges((es) => es.map((e) => ({ ...e, selected: e.id === edgeId }))); // Selection is applied in displayEdges; keep store flags clear.
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
}, },
[setNodes, setEdges], [setNodes, setEdges],
); );
@ -2780,14 +2816,22 @@ export function TopologyPage() {
/> />
{t("topology.hideVendor")} {t("topology.hideVendor")}
</label> </label>
<label className="topo-display-toggles__item"> <label className="topo-display-toggles__item">
<input <input
type="checkbox" type="checkbox"
checked={hidePorts} checked={hidePorts}
onChange={(e) => setHidePorts(e.target.checked)} onChange={(e) => setHidePorts(e.target.checked)}
/> />
{t("topology.hidePorts")} {t("topology.hidePorts")}
</label> </label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={expandPhysicalLinks}
onChange={(e) => setExpandPhysicalLinks(e.target.checked)}
/>
{t("topology.expandPhysicalLinks")}
</label>
<label className="topo-display-toggles__item"> <label className="topo-display-toggles__item">
<input <input
type="checkbox" type="checkbox"
@ -3312,6 +3356,7 @@ export function TopologyPage() {
)} )}
edges={displayEdges} edges={displayEdges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onlyRenderVisibleElements onlyRenderVisibleElements
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
defaultEdgeOptions={{ type: "straight" }} defaultEdgeOptions={{ type: "straight" }}
@ -3342,10 +3387,15 @@ export function TopologyPage() {
onNodesChange(changes); onNodesChange(changes);
}} }}
onEdgesChange={(changes) => { onEdgesChange={(changes) => {
if (changes.some((c) => c.type !== "select")) { const physical = changes.filter((c) => {
if (c.type === "select") return false;
if ("id" in c && isAggregateEdgeId(String(c.id))) return false;
return true;
});
if (physical.some((c) => c.type !== "select")) {
markDirty(); markDirty();
} }
onEdgesChange(changes); if (physical.length) onEdgesChange(physical);
}} }}
onConnect={onConnect} onConnect={onConnect}
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
@ -3676,13 +3726,49 @@ export function TopologyPage() {
<div className="topo-ctx__style-title">{t("topology.edgeStyle")}</div> <div className="topo-ctx__style-title">{t("topology.edgeStyle")}</div>
{selectedEdgeResolved ? ( {selectedEdgeResolved ? (
<div className="topo-ctx__style-grid"> <div className="topo-ctx__style-grid">
{Number(selectedEdgeData.member_count || 1) > 1 ? (
<div className="topo-ctx__style-members">
<div className="topo-ctx__style-title">
{t("topology.linkMembers").replace(
"{{count}}",
String(selectedEdgeData.member_count || 0),
)}
</div>
<ul className="topo-ctx__member-list">
{(selectedEdgeData.members || []).map((m) => (
<li key={m.id}>
{formatPortPairLabel(m.a_port, m.b_port) || m.id.slice(0, 8)}
</li>
))}
</ul>
{!expandPhysicalLinks ? (
<button
type="button"
className="btn btn--sm"
onClick={() => {
setExpandPhysicalLinks(true);
closeCtxMenu();
}}
>
{t("topology.expandPhysicalLinks")}
</button>
) : null}
</div>
) : null}
<label className="topo-ctx__style-row"> <label className="topo-ctx__style-row">
<span>{t("topology.sourcePort")}</span> <span>
{(selectedEdgeSourceNode?.data.label ||
selectedEdgeSourceNode?.data.ne_ip ||
t("topology.endpointA")) +
" · " +
t("topology.port")}
</span>
<input <input
type="text" type="text"
className="topo-ctx__style-input" className="topo-ctx__style-input"
value={selectedEdgeData.source_port || ""} value={selectedEdgeData.source_port || ""}
placeholder="?" placeholder="?"
disabled={Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks}
onFocus={() => pushHistory()} onFocus={() => pushHistory()}
onChange={(e) => onChange={(e) =>
patchSelectedEdgeStyle({ source_port: e.target.value }, { skipHistory: true }) patchSelectedEdgeStyle({ source_port: e.target.value }, { skipHistory: true })
@ -3690,12 +3776,19 @@ export function TopologyPage() {
/> />
</label> </label>
<label className="topo-ctx__style-row"> <label className="topo-ctx__style-row">
<span>{t("topology.targetPort")}</span> <span>
{(selectedEdgeTargetNode?.data.label ||
selectedEdgeTargetNode?.data.ne_ip ||
t("topology.endpointB")) +
" · " +
t("topology.port")}
</span>
<input <input
type="text" type="text"
className="topo-ctx__style-input" className="topo-ctx__style-input"
value={selectedEdgeData.target_port || ""} value={selectedEdgeData.target_port || ""}
placeholder="?" placeholder="?"
disabled={Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks}
onFocus={() => pushHistory()} onFocus={() => pushHistory()}
onChange={(e) => onChange={(e) =>
patchSelectedEdgeStyle({ target_port: e.target.value }, { skipHistory: true }) patchSelectedEdgeStyle({ target_port: e.target.value }, { skipHistory: true })

View file

@ -0,0 +1,53 @@
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getStraightPath } from "@xyflow/react";
/** Offset parallel physical links so they do not stack on one line. */
export function ParallelEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
style,
markerEnd,
label,
labelStyle,
labelBgStyle,
data,
selected,
}: EdgeProps) {
const index = Number((data as { parallelIndex?: number } | undefined)?.parallelIndex || 0);
const count = Math.max(1, Number((data as { parallelCount?: number } | undefined)?.parallelCount || 1));
const offset = (index - (count - 1) / 2) * 10;
const dx = targetX - sourceX;
const dy = targetY - sourceY;
const len = Math.hypot(dx, dy) || 1;
const ox = (-dy / len) * offset;
const oy = (dx / len) * offset;
const [path, labelX, labelY] = getStraightPath({
sourceX: sourceX + ox,
sourceY: sourceY + oy,
targetX: targetX + ox,
targetY: targetY + oy,
});
return (
<>
<BaseEdge id={id} path={path} style={style} markerEnd={markerEnd} />
{label ? (
<EdgeLabelRenderer>
<div
className={`topo-edge-label${selected ? " is-selected" : ""}`}
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: "none",
...labelStyle,
...(labelBgStyle || {}),
}}
>
{label}
</div>
</EdgeLabelRenderer>
) : null}
</>
);
}

View file

@ -0,0 +1,168 @@
import type { Edge } from "@xyflow/react";
export type LinkMember = {
id: string;
a_port: string;
b_port: string;
source: string;
};
export type LinkEdgeData = {
source?: string;
source_port?: string;
target_port?: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
discovered_at?: string | null;
/** Logical bundle of parallel physical links between the same NE pair. */
aggregated?: boolean;
member_count?: number;
members?: LinkMember[];
parallelIndex?: number;
parallelCount?: number;
};
export function pairKey(a: string, b: string): string {
return a < b ? `${a}::${b}` : `${b}::${a}`;
}
export function isAggregateEdgeId(id: string): boolean {
return String(id || "").startsWith("agg:");
}
export function aggregateIdForPair(a: string, b: string): string {
return `agg:${pairKey(a, b)}`;
}
/** Clear A↔B port label (not ambiguous "local/remote"). */
export function formatPortPairLabel(aPort: string, bPort: string): string {
const a = String(aPort || "").trim();
const b = String(bPort || "").trim();
if (a && b) return `${a} ↔ ${b}`;
return a || b || "";
}
export function formatBundleLabel(count: number, sample?: { a_port?: string; b_port?: string }): string {
if (count <= 1) return formatPortPairLabel(sample?.a_port || "", sample?.b_port || "");
const sampleLabel = formatPortPairLabel(sample?.a_port || "", sample?.b_port || "");
return sampleLabel ? `×${count} · ${sampleLabel}` : `×${count}`;
}
function memberFrom(edge: Edge): LinkMember {
const d = (edge.data || {}) as LinkEdgeData;
return {
id: edge.id,
a_port: String(d.source_port || "").trim(),
b_port: String(d.target_port || "").trim(),
source: String(d.source || "manual"),
};
}
function statusRank(source: string): number {
if (source === "stale") return 2;
if (source === "manual") return 1;
return 0; // discovered / lldp preferred
}
/** Pick representative physical edge for an aggregate (prefer live discovered). */
function pickPrimary(list: Edge[]): Edge {
return [...list].sort((a, b) => {
const da = (a.data || {}) as LinkEdgeData;
const db = (b.data || {}) as LinkEdgeData;
return statusRank(String(da.source || "")) - statusRank(String(db.source || ""));
})[0];
}
/**
* Default: one logical edge per NE pair (bundle parallel links).
* Expanded: all physical edges, offset when count > 1.
*/
export function buildLinkDisplayEdges(edges: Edge[], expandPhysical: boolean): Edge[] {
const groups = new Map<string, Edge[]>();
for (const e of edges) {
const k = pairKey(e.source, e.target);
const list = groups.get(k);
if (list) list.push(e);
else groups.set(k, [e]);
}
const out: Edge[] = [];
for (const [, list] of groups) {
if (expandPhysical) {
const count = list.length;
list.forEach((e, i) => {
const prev = (e.data || {}) as LinkEdgeData;
const data: LinkEdgeData = {
...prev,
aggregated: false,
member_count: count,
members: list.map(memberFrom),
parallelIndex: i,
parallelCount: count,
};
out.push({
...e,
type: count > 1 ? "topoParallel" : e.type || "straight",
label: formatPortPairLabel(data.source_port || "", data.target_port || "") || e.label,
data,
});
});
continue;
}
if (list.length === 1) {
const e = list[0];
const prev = (e.data || {}) as LinkEdgeData;
const data: LinkEdgeData = {
...prev,
aggregated: false,
member_count: 1,
members: [memberFrom(e)],
};
out.push({
...e,
type: "straight",
label: formatPortPairLabel(data.source_port || "", data.target_port || "") || e.label,
data,
});
continue;
}
const primary = pickPrimary(list);
const prev = (primary.data || {}) as LinkEdgeData;
const members = list.map(memberFrom);
const anyStale = members.some((m) => m.source === "stale");
const data: LinkEdgeData = {
...prev,
source: anyStale ? "stale" : prev.source,
aggregated: true,
member_count: list.length,
members,
// Slightly thicker logical bundle.
stroke_width: Math.max(Number(prev.stroke_width || 0), Math.min(8, 2 + list.length)),
};
out.push({
...primary,
id: aggregateIdForPair(primary.source, primary.target),
type: "straight",
label: formatBundleLabel(list.length, {
a_port: data.source_port,
b_port: data.target_port,
}),
data,
});
}
return out;
}
export function physicalIdsForDisplayEdge(edge: Edge | null | undefined, allEdges: Edge[]): string[] {
if (!edge) return [];
const d = (edge.data || {}) as LinkEdgeData;
if (d.members?.length) return d.members.map((m) => m.id);
if (isAggregateEdgeId(edge.id)) {
const k = edge.id.slice(4);
return allEdges.filter((e) => pairKey(e.source, e.target) === k).map((e) => e.id);
}
return [edge.id];
}