mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
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:
parent
6e7d84b782
commit
57f45fdaaf
7 changed files with 464 additions and 52 deletions
|
|
@ -217,8 +217,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
/** Updated whenever device stdout arrives; used to pace paste by echo, not fixed sleep. */
|
||||
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 [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [clipStatus, setClipStatus] = useState<string | null>(null);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [findQuery, setFindQuery] = useState("");
|
||||
const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null);
|
||||
|
|
@ -635,6 +638,40 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
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") {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
|
@ -656,6 +693,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
menuSelectionRef.current = term.getSelection() || "";
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
host.addEventListener("contextmenu", onContextMenu);
|
||||
|
|
@ -723,20 +761,30 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
sendStdinThrottled(text);
|
||||
if (text) sendStdinThrottled(text);
|
||||
else setClipStatus(t("webcrt.term.pasteEmpty"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
setClipStatus(t("webcrt.term.pasteFailed"));
|
||||
}
|
||||
setCtxMenu(null);
|
||||
focusTerminal();
|
||||
window.setTimeout(() => setClipStatus(null), 2200);
|
||||
};
|
||||
|
||||
const copySelection = async () => {
|
||||
const text = termRef.current?.getSelection() || "";
|
||||
if (text && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const text = menuSelectionRef.current || termRef.current?.getSelection() || "";
|
||||
try {
|
||||
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);
|
||||
window.setTimeout(() => setClipStatus(null), 2200);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -746,6 +794,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
{t("webcrt.term.pasting", { done: pasteStatus.done, total: pasteStatus.total })}
|
||||
</div>
|
||||
) : null}
|
||||
{clipStatus ? (
|
||||
<div className="webcrt-paste-status" role="status" aria-live="polite">
|
||||
{clipStatus}
|
||||
</div>
|
||||
) : null}
|
||||
{findOpen ? (
|
||||
<div className="webcrt-find">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1226,6 +1226,8 @@ const en = {
|
|||
findClose: "Close find",
|
||||
copy: "Copy",
|
||||
paste: "Paste",
|
||||
pasteEmpty: "Clipboard is empty",
|
||||
pasteFailed: "Cannot read clipboard (try Ctrl+Shift+V or check browser permission)",
|
||||
clear: "Clear",
|
||||
pasting: "Pasting {{done}}/{{total}} lines…",
|
||||
wsError: "[WebSocket error]",
|
||||
|
|
@ -1335,6 +1337,11 @@ const en = {
|
|||
renameNodePrompt: "Node display name",
|
||||
sourcePort: "Local port",
|
||||
targetPort: "Remote port",
|
||||
endpointA: "Endpoint A",
|
||||
endpointB: "Endpoint B",
|
||||
port: "port",
|
||||
expandPhysicalLinks: "Expand physical links",
|
||||
linkMembers: "Physical members ({{count}})",
|
||||
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.",
|
||||
selected: "Selected",
|
||||
|
|
|
|||
|
|
@ -1220,6 +1220,8 @@ const zh = {
|
|||
findClose: "关闭查找",
|
||||
copy: "复制",
|
||||
paste: "粘贴",
|
||||
pasteEmpty: "剪贴板为空",
|
||||
pasteFailed: "无法读取剪贴板(请用 Ctrl+Shift+V 或检查浏览器权限)",
|
||||
clear: "清空",
|
||||
pasting: "粘贴中 {{done}}/{{total}} 行…",
|
||||
wsError: "[WebSocket 错误]",
|
||||
|
|
@ -1329,6 +1331,11 @@ const zh = {
|
|||
renameNodePrompt: "网元显示名称",
|
||||
sourcePort: "本端端口",
|
||||
targetPort: "对端端口",
|
||||
endpointA: "端点 A",
|
||||
endpointB: "端点 B",
|
||||
port: "端口",
|
||||
expandPhysicalLinks: "展开物理链路",
|
||||
linkMembers: "物理成员({{count}})",
|
||||
canvasHint:
|
||||
"左侧为区域目录(物理拓扑 + 自定义图);打开本图后进入设备画布,用「添加网元」上图并保存。",
|
||||
selected: "已选节点",
|
||||
|
|
|
|||
|
|
@ -6728,6 +6728,37 @@ pre {
|
|||
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) {
|
||||
.topo-page,
|
||||
.topo-page.is-sidebar-collapsed {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@ import type {
|
|||
TopologyViewRole,
|
||||
} from "../types";
|
||||
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";
|
||||
|
||||
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 edgeTypes = { topoParallel: ParallelEdge };
|
||||
|
||||
type EdgeStyleData = {
|
||||
source?: string;
|
||||
source_port?: string;
|
||||
target_port?: string;
|
||||
stroke_color?: string;
|
||||
stroke_width?: number;
|
||||
line_style?: string;
|
||||
discovered_at?: string | null;
|
||||
};
|
||||
type EdgeStyleData = LinkEdgeData;
|
||||
|
||||
type EdgeLineStyle = "solid" | "dashed" | "dotted";
|
||||
type EdgeSourceKind = "manual" | "discovered" | "stale";
|
||||
|
|
@ -603,7 +605,7 @@ function graphToFlow(
|
|||
const rfEdges: Edge[] = edges.map((e) => {
|
||||
const src =
|
||||
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 = {
|
||||
source: src,
|
||||
source_port: e.a_port || "",
|
||||
|
|
@ -705,6 +707,8 @@ export function TopologyPage() {
|
|||
const [hideIp, setHideIp] = useState(true);
|
||||
const [hideVendor, setHideVendor] = 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 [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
|
||||
const [toolMode, setToolMode] = useState<ToolMode>("select");
|
||||
|
|
@ -812,15 +816,25 @@ export function TopologyPage() {
|
|||
() => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect" }),
|
||||
[hideIp, hideVendor, hidePorts, toolMode],
|
||||
);
|
||||
const displayEdges = useMemo(
|
||||
() =>
|
||||
edges.map((e) => ({
|
||||
const displayEdges = useMemo(() => {
|
||||
const built = buildLinkDisplayEdges(edges, expandPhysicalLinks).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,
|
||||
label: hidePorts ? undefined : e.label,
|
||||
selected,
|
||||
label: hidePorts && !keepCountLabel ? undefined : e.label,
|
||||
animated: edgeFlow,
|
||||
})),
|
||||
[edges, hidePorts, edgeFlow],
|
||||
);
|
||||
};
|
||||
});
|
||||
}, [edges, expandPhysicalLinks, hidePorts, edgeFlow, edgeDefaults, selectedEdgeId]);
|
||||
|
||||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
|
|
@ -1593,12 +1607,18 @@ export function TopologyPage() {
|
|||
const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]);
|
||||
const selectedNode = selectedNodes[0] || null;
|
||||
const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]);
|
||||
const selectedEdge = useMemo(
|
||||
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) || null : null),
|
||||
[edges, selectedEdgeId],
|
||||
);
|
||||
const selectedEdge = useMemo(() => {
|
||||
if (!selectedEdgeId) return null;
|
||||
return (
|
||||
displayEdges.find((e) => e.id === selectedEdgeId) ||
|
||||
edges.find((e) => e.id === selectedEdgeId) ||
|
||||
null
|
||||
);
|
||||
}, [selectedEdgeId, displayEdges, edges]);
|
||||
const selectedEdgeData = (selectedEdge?.data || {}) as EdgeStyleData;
|
||||
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(
|
||||
(
|
||||
|
|
@ -1608,10 +1628,20 @@ export function TopologyPage() {
|
|||
opts?: { skipHistory?: boolean },
|
||||
) => {
|
||||
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();
|
||||
setEdges((eds) =>
|
||||
eds.map((e) => {
|
||||
if (e.id !== selectedEdgeId) return e;
|
||||
if (!targetIds.includes(e.id)) return e;
|
||||
const prev = (e.data || {}) as EdgeStyleData;
|
||||
const data: EdgeStyleData = {
|
||||
...prev,
|
||||
|
|
@ -1622,21 +1652,23 @@ export function TopologyPage() {
|
|||
source_port: patch.source_port !== undefined ? patch.source_port : prev.source_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);
|
||||
}),
|
||||
);
|
||||
const edge = edges.find((e) => e.id === selectedEdgeId);
|
||||
const edge = edges.find((e) => e.id === targetIds[0]);
|
||||
const prev = (edge?.data || {}) as EdgeStyleData;
|
||||
void patchTopologyEdgeStyle(mapId, {
|
||||
fabric_edge_id: selectedEdgeId,
|
||||
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
|
||||
stroke_width:
|
||||
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
|
||||
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
|
||||
}).catch((err) => showError(String(err)));
|
||||
for (const fabricEdgeId of targetIds) {
|
||||
void patchTopologyEdgeStyle(mapId, {
|
||||
fabric_edge_id: fabricEdgeId,
|
||||
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
|
||||
stroke_width:
|
||||
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
|
||||
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(() => {
|
||||
|
|
@ -1822,8 +1854,11 @@ export function TopologyPage() {
|
|||
const removeEdgeById = (edgeId: string) => {
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setEdges((es) => es.filter((e) => e.id !== edgeId));
|
||||
setSelectedEdgeId((cur) => (cur === edgeId ? null : cur));
|
||||
const display = displayEdges.find((e) => e.id === edgeId);
|
||||
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();
|
||||
};
|
||||
|
||||
|
|
@ -2215,7 +2250,8 @@ export function TopologyPage() {
|
|||
(edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
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],
|
||||
);
|
||||
|
|
@ -2780,14 +2816,22 @@ export function TopologyPage() {
|
|||
/>
|
||||
{t("topology.hideVendor")}
|
||||
</label>
|
||||
<label className="topo-display-toggles__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hidePorts}
|
||||
onChange={(e) => setHidePorts(e.target.checked)}
|
||||
/>
|
||||
{t("topology.hidePorts")}
|
||||
</label>
|
||||
<label className="topo-display-toggles__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hidePorts}
|
||||
onChange={(e) => setHidePorts(e.target.checked)}
|
||||
/>
|
||||
{t("topology.hidePorts")}
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -3312,6 +3356,7 @@ export function TopologyPage() {
|
|||
)}
|
||||
edges={displayEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onlyRenderVisibleElements
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
defaultEdgeOptions={{ type: "straight" }}
|
||||
|
|
@ -3342,10 +3387,15 @@ export function TopologyPage() {
|
|||
onNodesChange(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();
|
||||
}
|
||||
onEdgesChange(changes);
|
||||
if (physical.length) onEdgesChange(physical);
|
||||
}}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
|
|
@ -3676,13 +3726,49 @@ export function TopologyPage() {
|
|||
<div className="topo-ctx__style-title">{t("topology.edgeStyle")}</div>
|
||||
{selectedEdgeResolved ? (
|
||||
<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">
|
||||
<span>{t("topology.sourcePort")}</span>
|
||||
<span>
|
||||
{(selectedEdgeSourceNode?.data.label ||
|
||||
selectedEdgeSourceNode?.data.ne_ip ||
|
||||
t("topology.endpointA")) +
|
||||
" · " +
|
||||
t("topology.port")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="topo-ctx__style-input"
|
||||
value={selectedEdgeData.source_port || ""}
|
||||
placeholder="?"
|
||||
disabled={Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks}
|
||||
onFocus={() => pushHistory()}
|
||||
onChange={(e) =>
|
||||
patchSelectedEdgeStyle({ source_port: e.target.value }, { skipHistory: true })
|
||||
|
|
@ -3690,12 +3776,19 @@ export function TopologyPage() {
|
|||
/>
|
||||
</label>
|
||||
<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
|
||||
type="text"
|
||||
className="topo-ctx__style-input"
|
||||
value={selectedEdgeData.target_port || ""}
|
||||
placeholder="?"
|
||||
disabled={Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks}
|
||||
onFocus={() => pushHistory()}
|
||||
onChange={(e) =>
|
||||
patchSelectedEdgeStyle({ target_port: e.target.value }, { skipHistory: true })
|
||||
|
|
|
|||
53
web/src/pages/topology/ParallelEdge.tsx
Normal file
53
web/src/pages/topology/ParallelEdge.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
web/src/pages/topology/linkDisplay.ts
Normal file
168
web/src/pages/topology/linkDisplay.ts
Normal 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];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue