Improve frontend load performance and fix UME/login UX.

Defer topology xyflow and WebCRT xterm until needed, calm idle polling, bake login earth brightness, and let AI alert rules scroll as one natural panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-07 22:54:42 +08:00
parent a0170907e4
commit 9bcb8ee0e3
11 changed files with 906 additions and 663 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View file

@ -7836,13 +7836,11 @@ pre {
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(8, 28, 72, 0.2); background-color: rgba(8, 28, 72, 0.2);
background-image: url("/login-earth-night.webp"); /* Pre-brightened asset — no live CSS filter (that was the GPU thrash). */
background-image: url("/login-earth-night-bright.webp");
background-repeat: repeat-x; background-repeat: repeat-x;
background-size: 200%; background-size: 200%;
/* Softer map — less crunchy, blends into the limb fade. */
filter: brightness(2.45) contrast(1.02) saturate(1.08);
animation: login-earth-spin 80s linear infinite; animation: login-earth-spin 80s linear infinite;
will-change: background-position;
} }
.login-page__rail { .login-page__rail {
@ -7866,9 +7864,10 @@ pre {
padding: 26px 24px 28px; padding: 26px 24px 28px;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(8, 16, 32, 0.58); background: rgba(8, 16, 32, 0.82);
backdrop-filter: blur(16px) saturate(1.12); /* Lighter blur — full blur over the spinning globe was a steady GPU cost. */
-webkit-backdrop-filter: blur(16px) saturate(1.12); backdrop-filter: blur(8px) saturate(1.08);
-webkit-backdrop-filter: blur(8px) saturate(1.08);
box-shadow: box-shadow:
0 18px 48px rgba(0, 0, 0, 0.32), 0 18px 48px rgba(0, 0, 0, 0.32),
0 0 0 1px rgba(120, 170, 255, 0.07), 0 0 0 1px rgba(120, 170, 255, 0.07),
@ -8129,6 +8128,12 @@ pre {
to { opacity: 1; transform: scale(1.08); } to { opacity: 1; transform: scale(1.08); }
} }
/* Pause infinite loops when the login tab is backgrounded (class set by LoginShell). */
html.login-page--paused .login-planet__textures,
html.login-page--paused .login-page__flare {
animation-play-state: paused !important;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
/* Keep ambient earth spin; only drop entrance flourishes. */ /* Keep ambient earth spin; only drop entrance flourishes. */
.login-planet, .login-planet,
@ -8557,6 +8562,19 @@ pre {
padding: 12px 14px; padding: 12px 14px;
} }
/* Form + list share one scroll body (no cramped nested table viewport). */
.ops-detail-modal__scroll--flow {
border: 0;
background: transparent;
border-radius: 0;
}
.ops-detail-modal__scroll--flow .pt-list-table-wrap {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 8px;
overflow: visible;
}
.ume-entry-grid { .ume-entry-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));

View file

@ -1,7 +1,21 @@
import type { ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
/** Auth shell: night-earth stage on the left, form rail on the right. */ /** Auth shell: night-earth stage on the left, form rail on the right. */
export function LoginShell({ children }: { children: ReactNode }) { export function LoginShell({ children }: { children: ReactNode }) {
// Pause ambient CSS loops while the tab is hidden.
useEffect(() => {
const root = document.documentElement;
const sync = () => {
root.classList.toggle("login-page--paused", document.hidden);
};
sync();
document.addEventListener("visibilitychange", sync);
return () => {
document.removeEventListener("visibilitychange", sync);
root.classList.remove("login-page--paused");
};
}, []);
return ( return (
<div className="login-page"> <div className="login-page">
<div className="login-page__space" aria-hidden="true"> <div className="login-page__space" aria-hidden="true">

View file

@ -1,9 +1,8 @@
import { import {
Fragment, Fragment,
createContext, lazy,
memo, Suspense,
useCallback, useCallback,
useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
@ -14,28 +13,13 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import type {
ReactFlow, Connection,
Background, Edge,
BackgroundVariant, Node,
Controls,
ControlButton,
MiniMap,
addEdge,
useEdgesState,
useNodesState,
Handle,
Position, Position,
ConnectionLineType, ReactFlowInstance,
ConnectionMode,
SelectionMode,
type Connection,
type Edge,
type Node,
type NodeProps,
type ReactFlowInstance,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { import {
addTopologyViewNodes, addTopologyViewNodes,
createFabricManualEdge, createFabricManualEdge,
@ -70,24 +54,19 @@ import { HelpHint } from "../components/HelpHint";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import { openOrFocusModule } from "../utils/moduleWindows"; import { openOrFocusModule } from "../utils/moduleWindows";
import { WorldScatterLayer } from "./topology/WorldScatterLayer";
import type { import type {
FabricNodeSearchHit, FabricNodeSearchHit,
TopologyDiscoverJob, TopologyDiscoverJob,
TopologyDiscoverNeResult, TopologyDiscoverNeResult,
TopologyDiscoverOut, TopologyDiscoverOut,
TopologyEdgeItem,
TopologyNodeItem,
TopologyTreeFolderItem, TopologyTreeFolderItem,
TopologyTreeViewItem, TopologyTreeViewItem,
TopologyViewEdgeItem, TopologyViewEdgeItem,
TopologyViewGraph, TopologyViewGraph,
TopologyViewNodeItem, TopologyViewNodeItem,
TopologyViewRole,
TopologyWorldTransform, TopologyWorldTransform,
} 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 { import {
buildLinkDisplayEdges, buildLinkDisplayEdges,
formatPortPairLabel, formatPortPairLabel,
@ -97,6 +76,16 @@ import {
type LinkMember, type LinkMember,
} from "./topology/linkDisplay"; } from "./topology/linkDisplay";
import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode"; import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode";
import type { NeNodeData } from "./topology/TopologyReactFlowView";
/** xyflow + canvas nodes — only loaded when a map canvas is open. */
const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowView"));
function isPlaceholderSource(source: string | undefined, neIp: string): boolean {
const src = String(source || "").trim().toLowerCase();
if (src === "lldp" || src === "topology") return true;
return !String(neIp || "").trim() && Boolean(src);
}
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId"; const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
const TREE_EXPAND_KEY = "netx.topology.treeExpanded"; const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
@ -308,17 +297,6 @@ function formatNeCount(count: number): string {
return `${n}N`; return `${n}N`;
} }
function formatUpdatedAt(value?: string | null): string {
if (!value) return "";
try {
return new Date(value).toLocaleString();
} catch {
return String(value);
}
}
const SNAP_GRID: [number, number] = [16, 16];
/** /**
* onlyRenderVisibleElements leaves off-screen nodes unmeasured; default fitView * onlyRenderVisibleElements leaves off-screen nodes unmeasured; default fitView
* skips those. includeHiddenNodes uses declared width/height so fit still works. * skips those. includeHiddenNodes uses declared width/height so fit still works.
@ -402,27 +380,6 @@ function mergeFlatWorldGraph(
const UNDO_MAX = 40; const UNDO_MAX = 40;
const PALETTE_DND = "application/x-netx-topo-palette"; const PALETTE_DND = "application/x-netx-topo-palette";
function FullscreenIcon({ exit }: { exit?: boolean }) {
if (exit) {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm12 0h-2v3h-3v2h5v-5zM7 5h3V3H5v5h2V5zm10 0v3h2V3h-5v2h3z"
/>
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
/>
</svg>
);
}
/** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */ /** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */
const SEP = " / "; const SEP = " / ";
@ -526,23 +483,6 @@ function LayerGlyph({ role, size = 16 }: { role?: string; size?: number }) {
); );
} }
type NeNodeData = {
label: string;
managed_ne_id: string;
ume_ne_id: string;
ne_ip: string;
vendor: string;
connect_status: string;
managed_source?: string;
/** Canvas navigation node (not a fabric NE). */
kind?: "ne" | "region" | "layer";
folder_id?: string;
view_id?: string;
role?: TopologyViewRole | string;
subtitle?: string;
node_count?: number;
};
type HistorySnap = { type HistorySnap = {
nodes: Node<NeNodeData>[]; nodes: Node<NeNodeData>[];
edges: Edge[]; edges: Edge[];
@ -564,32 +504,12 @@ type PaletteItem = {
connect_status: string; connect_status: string;
}; };
type TopoDisplayOpts = {
hideIp: boolean;
hideVendor: boolean;
hidePorts: boolean;
connectMode: boolean;
/** Show TOPO/LLDP placeholder corner badge on nodes. */
showPlaceholderBadge: boolean;
/** Flat world map visual LOD (Google-Earth style). */
worldVisualLod: "dot" | "pin" | "full";
};
type CtxMenu = type CtxMenu =
| { kind: "node"; id: string; x: number; y: number } | { kind: "node"; id: string; x: number; y: number }
| { kind: "edge"; id: string; x: number; y: number } | { kind: "edge"; id: string; x: number; y: number }
| { kind: "selection"; x: number; y: number } | { kind: "selection"; x: number; y: number }
| { kind: "pane"; x: number; y: number; flowX: number; flowY: number }; | { kind: "pane"; x: number; y: number; flowX: number; flowY: number };
const TopoDisplayContext = createContext<TopoDisplayOpts>({
hideIp: true,
hideVendor: true,
hidePorts: true,
connectMode: false,
showPlaceholderBadge: false,
worldVisualLod: "full",
});
function newId(): string { function newId(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) { if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID().replace(/-/g, ""); return crypto.randomUUID().replace(/-/g, "");
@ -606,31 +526,6 @@ function isLocalPendingEdgeId(id: string): boolean {
return String(id || "").startsWith("local:"); return String(id || "").startsWith("local:");
} }
/**
* Icon tone for /topo/ne-router.png (base = blue).
* Unmanaged (no managed/ume) and unknown vendors → gray.
* UME-linked NEs default to ZTE when vendor is empty/unknown.
*/
function nodeIconTone(vendor: string, managedNeId: string, umeNeId = ""): string {
const hasManaged = Boolean(String(managedNeId || "").trim());
const hasUme = Boolean(String(umeNeId || "").trim());
if (!hasManaged && !hasUme) return "gray";
const v = String(vendor || "").trim().toLowerCase();
if (!v || v === "other" || v === "unknown" || v === "generic") {
return hasUme ? "zte" : "gray";
}
if (v.includes("cisco")) return "cisco";
if (v.includes("huawei")) return "huawei";
if (v.includes("zte")) return "zte";
if (v.includes("juniper")) return "juniper";
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
if (v.includes("ericsson")) return "ericsson";
if (v.includes("h3c") || v.includes("comware")) return "h3c";
if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie";
if (v.includes("mikrotik")) return "mikrotik";
return hasUme ? "zte" : "gray";
}
function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" { function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" {
if (!r.ok) return "fail"; if (!r.ok) return "fail";
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0); const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
@ -670,33 +565,6 @@ function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok))); return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
} }
/**
* Keep the PNG’s white texture/highlights; tint brand color via mix-blend-mode.
* Color comes from --topo-icon-color (vendor palette in Display settings).
*/
function RouterIcon() {
return (
<span className="topo-node__icon" aria-hidden="true">
<img className="topo-node__icon-art" src="/topo/ne-router.png" alt="" draggable={false} />
<span className="topo-node__icon-tint" />
</span>
);
}
/** Canvas sub-region building art (UME-style); no vendor tint. */
function RegionCanvasIcon() {
return (
<span className="topo-node__icon topo-node__icon--region" aria-hidden="true">
<img
className="topo-node__icon-art"
src="/topo/region-building.png"
alt=""
draggable={false}
/>
</span>
);
}
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). /** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set).
* Caption is absolutely positioned under the glyph so the RF box is icon-only. */ * Caption is absolutely positioned under the glyph so the RF box is icon-only. */
const TOPO_NODE_W = 80; const TOPO_NODE_W = 80;
@ -745,99 +613,6 @@ function neApiPosition(flowX: number, flowY: number): { x: number; y: number } {
return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y); return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
} }
function isPlaceholderSource(source: string | undefined, neIp: string): boolean {
const src = String(source || "").trim().toLowerCase();
if (src === "lldp" || src === "topology") return true;
return !String(neIp || "").trim() && Boolean(src);
}
const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
const { hideIp, hideVendor, connectMode, showPlaceholderBadge, worldVisualLod } =
useContext(TopoDisplayContext);
const isRegion = data.kind === "region";
const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip);
const showBadge = placeholder && showPlaceholderBadge;
const name = data.label || (!hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
// Region count stays in the title "(N)"; do not repeat under the icon.
const secondary = isRegion
? []
: [
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
hideVendor || !data.vendor ? "" : data.vendor,
].filter(Boolean);
if (!isRegion && worldVisualLod === "dot") {
return (
<div
className={`topo-node topo-node--dot topo-node--${tone}${selected ? " is-selected" : ""}`}
title={name}
>
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
<span className="topo-node__pixel" aria-hidden="true" />
</div>
);
}
if (!isRegion && worldVisualLod === "pin") {
return (
<div
className={`topo-node topo-node--pin topo-node--${tone}${selected ? " is-selected" : ""}`}
title={name}
>
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
<span className="topo-node__pin" aria-hidden="true" />
</div>
);
}
return (
<div
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
connectMode && !isRegion ? " is-connect-mode" : ""
}${showBadge ? " is-placeholder" : ""}${isRegion ? " is-region" : ""}`}
title={
isRegion
? name
: showBadge
? data.managed_source || "placeholder"
: name
}
>
<div className="topo-node__glyph">
<Handle
type="target"
position={Position.Left}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode && !isRegion}
/>
<Handle
type="source"
position={Position.Right}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode && !isRegion}
/>
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
{showBadge ? (
<span className="topo-node__badge" aria-hidden>
{String(data.managed_source || "ph").slice(0, 4)}
</span>
) : null}
</div>
<div className="topo-node__caption">
<span className="topo-node__caption-name">{name}</span>
{secondary.length ? (
<span className="topo-node__caption-meta">{secondary.join(SEP)}</span>
) : null}
</div>
</div>
);
});
const nodeTypes = { neNode: NeNode };
const edgeTypes = { topoParallel: ParallelEdge };
type EdgeStyleData = LinkEdgeData; type EdgeStyleData = LinkEdgeData;
type EdgeLineStyle = "solid" | "dashed" | "dotted"; type EdgeLineStyle = "solid" | "dashed" | "dotted";
@ -972,16 +747,6 @@ function persistVendorColors(value: VendorColors) {
} }
} }
function canvasDotColor(bg: string): string {
const hex = String(bg || "").replace("#", "");
if (hex.length !== 6) return "#93c5fd";
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.55 ? "#93c5fd" : "#64748b";
}
function loadBoolFlag(key: string, defaultValue: boolean): boolean { function loadBoolFlag(key: string, defaultValue: boolean): boolean {
try { try {
const raw = localStorage.getItem(key); const raw = localStorage.getItem(key);
@ -1138,8 +903,8 @@ function graphToFlow(
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H, height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
// Predetermined handles must match DOM anchors (icon center). // Predetermined handles must match DOM anchors (icon center).
handles: [ handles: [
{ type: "target", position: Position.Left, x: hx, y: hy }, { type: "target", position: "left" as Position, x: hx, y: hy },
{ type: "source", position: Position.Right, x: hx, y: hy }, { type: "source", position: "right" as Position, x: hx, y: hy },
], ],
data: { data: {
label: n.label || n.name || n.ip || n.fabric_node_id, label: n.label || n.name || n.ip || n.fabric_node_id,
@ -1328,8 +1093,8 @@ export function TopologyPage() {
const displayMenuRef = useRef<HTMLDetailsElement | null>(null); const displayMenuRef = useRef<HTMLDetailsElement | null>(null);
const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState<HTMLDivElement | null>(null); const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState<HTMLDivElement | null>(null);
const findJustLocatedRef = useRef(false); const findJustLocatedRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]); const [nodes, setNodes] = useState<Node<NeNodeData>[]>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges] = useState<Edge[]>([]);
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null); const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null); const canvasRef = useRef<HTMLDivElement | null>(null);
const dirtyRef = useRef(false); const dirtyRef = useRef(false);
@ -1672,6 +1437,12 @@ export function TopologyPage() {
const canvasMode = Boolean(mapId); const canvasMode = Boolean(mapId);
useEffect(() => {
if (!canvasMode) return;
void import("./topology/TopologyReactFlowView");
}, [canvasMode]);
const activeRegion = useMemo(() => { const activeRegion = useMemo(() => {
if (!selectedFolderId) return null; if (!selectedFolderId) return null;
return findFolderInTree(regions, selectedFolderId); return findFolderInTree(regions, selectedFolderId);
@ -2102,7 +1873,7 @@ export function TopologyPage() {
return; return;
} }
pushHistory(); pushHistory();
const next = layoutGraph(nodes, edges, kind, { onlyIds }); const next = await layoutGraph(nodes, edges, kind, { onlyIds });
setNodes(next); setNodes(next);
markDirty(); markDirty();
if (opts?.persist && mapId) { if (opts?.persist && mapId) {
@ -2765,7 +2536,7 @@ export function TopologyPage() {
}); });
let didAutoLayout = false; let didAutoLayout = false;
if (autoLayoutAfterDiscover && rfNodes.length > 1) { if (autoLayoutAfterDiscover && rfNodes.length > 1) {
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb"); rfNodes = await layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
didAutoLayout = true; didAutoLayout = true;
} }
historyLockRef.current = true; historyLockRef.current = true;
@ -5430,7 +5201,7 @@ export function TopologyPage() {
</div> </div>
) : null} ) : null}
{treeRoot ? ( {treeRoot ? (
<TopoDisplayContext.Provider value={displayOpts}> <>
{isWorldFlatCanvas ? ( {isWorldFlatCanvas ? (
<div className="topo-world-hud" role="status"> <div className="topo-world-hud" role="status">
<span> <span>
@ -5445,186 +5216,57 @@ export function TopologyPage() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
<ReactFlow <Suspense
nodes={ fallback={
isWorldFlatCanvas && worldVisualLod !== "full" <div className="topo-canvas__empty topo-canvas__status" role="status">
? [] <span className="topo-loading-spinner" aria-hidden="true" />
: nodes.map((n) => { <p>{t("topology.treeLoading")}</p>
const isRegion = n.data.kind === "region"; </div>
const lod = isWorldFlatCanvas ? worldVisualLod : "full";
const sized =
!isRegion && lod === "dot"
? { ...n, width: 6, height: 6 }
: !isRegion && lod === "pin"
? { ...n, width: 14, height: 14 }
: n;
return searchHitIds.includes(n.id)
? {
...sized,
selected: true,
className: "is-search-hit",
} }
: { ...sized, className: undefined }; >
}) <TopologyReactFlowView
} displayOpts={displayOpts}
edges={isWorldFlatCanvas && worldVisualLod !== "full" ? [] : displayEdges} nodes={nodes}
nodeTypes={nodeTypes} setNodes={setNodes}
edgeTypes={edgeTypes} edges={edges}
onlyRenderVisibleElements={ setEdges={setEdges}
isWorldFlatCanvas ? worldVisualLod === "dot" : true displayEdges={displayEdges}
} searchHitIds={searchHitIds}
connectionMode={ConnectionMode.Loose} isWorldFlatCanvas={isWorldFlatCanvas}
connectionLineType={ConnectionLineType.Straight} worldVisualLod={worldVisualLod}
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }} toolBehavior={toolBehavior}
defaultEdgeOptions={{ type: "straight", labelShowBg: false }} toolMode={toolMode}
proOptions={{ hideAttribution: true }}
minZoom={isWorldFlatCanvas ? 0.002 : 0.05}
maxZoom={isWorldFlatCanvas ? 12 : 4}
nodesDraggable={
isWorldFlatCanvas
? worldVisualLod === "full" && toolBehavior.nodesDraggable
: toolBehavior.nodesDraggable
}
nodesConnectable={toolBehavior.nodesConnectable}
elementsSelectable={toolBehavior.elementsSelectable}
panOnDrag={toolBehavior.panOnDrag}
selectionOnDrag={toolBehavior.selectionOnDrag}
panOnScroll={toolBehavior.panOnScroll}
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode="Shift"
snapToGrid={snapToGrid} snapToGrid={snapToGrid}
snapGrid={SNAP_GRID} canvasBg={canvasBg}
onNodeDragStart={() => { worldScatter={worldScatter}
pushHistory(); showWorldScatter={showWorldScatter}
}} fullscreen={fullscreen}
onNodesChange={(changes) => { connectHint={t("topology.connectHint")}
if ( fullscreenLabel={t("topology.fullscreen")}
changes.some( exitFullscreenLabel={t("topology.exitFullscreen")}
(c) => c.type === "position" || c.type === "remove" || c.type === "add", worldTransform={worldTransformRef.current || graphQuery.data?.world_transform}
) nodesRef={nodesRef}
) { rfRef={rfRef}
markDirty(); pendingFitRef={pendingFitRef}
} markDirty={markDirty}
onNodesChange(changes); pushHistory={pushHistory}
}}
onEdgesChange={(changes) => {
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();
}
if (physical.length) onEdgesChange(physical);
}}
onConnect={onConnect} onConnect={onConnect}
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick} onNodeDoubleClick={onNodeDoubleClick}
onEdgeClick={(_e, edge) => { focusEdge={focusEdge}
setCtxMenu(null); focusNode={focusNode}
focusEdge(edge.id); clearSelection={clearSelection}
}} setCtxMenu={setCtxMenu}
onPaneClick={() => { placeCtxMenu={placeCtxMenu}
setCtxMenu(null); selectedNodeIds={selectedNodeIds}
clearSelection(); closeCtxMenu={closeCtxMenu}
}} setCanvasZoom={setCanvasZoom}
onNodeContextMenu={(e, node) => { scheduleFlatViewportRefresh={scheduleFlatViewportRefresh}
e.preventDefault(); toggleFullscreen={toggleFullscreen}
const multi = selectedNodeIds.length > 1 && selectedNodeIds.includes(node.id);
if (!multi) focusNode(node.id, false);
const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu(
multi ? { kind: "selection", ...pos } : { kind: "node", id: node.id, ...pos },
);
}}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
focusEdge(edge.id);
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 260, h: 280 });
setCtxMenu({ kind: "edge", id: edge.id, ...pos });
}}
onSelectionContextMenu={(e) => {
e.preventDefault();
const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu({ kind: "selection", ...pos });
}}
onPaneContextMenu={(e) => {
e.preventDefault();
if (!rfRef.current) return;
const flow = rfRef.current.screenToFlowPosition({
x: e.clientX,
y: e.clientY,
});
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 180, h: 100 });
setCtxMenu({
kind: "pane",
...pos,
flowX: flow.x,
flowY: flow.y,
});
}}
onMoveStart={closeCtxMenu}
onMove={(_e, vp) => {
if (isWorldFlatCanvas) setCanvasZoom(vp.zoom);
}}
onMoveEnd={(_e, vp) => {
if (!isWorldFlatCanvas) return;
setCanvasZoom(vp.zoom);
scheduleFlatViewportRefresh();
}}
onInit={(inst) => {
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
if (pendingFitRef.current) {
pendingFitRef.current = false;
window.requestAnimationFrame(() => {
const bounds = isWorldFlatCanvas
? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform)
: null;
if (bounds) {
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
} else if (nodesRef.current.length > 0) {
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
}
setCanvasZoom(inst.getZoom());
});
}
}}
fitView={false}
deleteKeyCode={null}
edgesFocusable
>
<WorldScatterLayer
points={worldScatter}
mode={worldVisualLod === "pin" ? "pin" : "dot"}
visible={showWorldScatter}
/> />
<Background </Suspense>
variant={BackgroundVariant.Dots} </>
gap={16}
size={1}
color={canvasDotColor(canvasBg)}
bgColor={canvasBg}
/>
{toolMode === "connect" ? (
<div className="topo-mode-hint" role="status">
{t("topology.connectHint")}
</div>
) : null}
<Controls showInteractive>
<ControlButton
className="topo-fs-control"
onClick={() => void toggleFullscreen()}
title={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
aria-label={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
>
<FullscreenIcon exit={fullscreen} />
</ControlButton>
</Controls>
{!isWorldFlatCanvas ? <MiniMap pannable zoomable /> : null}
</ReactFlow>
</TopoDisplayContext.Provider>
) : treeLoading ? ( ) : treeLoading ? (
<div className="topo-canvas__empty topo-canvas__status" role="status"> <div className="topo-canvas__empty topo-canvas__status" role="status">
<span className="topo-loading-spinner" aria-hidden="true" /> <span className="topo-loading-spinner" aria-hidden="true" />

View file

@ -70,14 +70,22 @@ export function UmePage() {
const syncStatusQuery = useQuery({ const syncStatusQuery = useQuery({
queryKey: queryKeys.umeSyncStatus(syncPage, syncPageSize), queryKey: queryKeys.umeSyncStatus(syncPage, syncPageSize),
queryFn: () => fetchUmeSyncStatus({ page: syncPage, pageSize: syncPageSize }), queryFn: () => fetchUmeSyncStatus({ page: syncPage, pageSize: syncPageSize }),
staleTime: 5000, staleTime: 10_000,
refetchInterval: 5000, // Panel open or active sync → keep snappy; otherwise idle cards only need a slow heartbeat.
refetchInterval: (q) => {
const items = q.state.data?.items || [];
const running = items.some((x) => String(x.status || "").toLowerCase() === "running");
if (syncStatusPanelOpen || running) return 5000;
return 20_000;
},
refetchIntervalInBackground: false,
}); });
const tokenStatusQuery = useQuery({ const tokenStatusQuery = useQuery({
queryKey: queryKeys.umeTokenStatus, queryKey: queryKeys.umeTokenStatus,
queryFn: fetchUmeTokenStatus, queryFn: fetchUmeTokenStatus,
staleTime: 3000, staleTime: 15_000,
refetchInterval: 5000, refetchInterval: 20_000,
refetchIntervalInBackground: false,
}); });
const tokenRefreshMutation = useMutation({ const tokenRefreshMutation = useMutation({
mutationFn: refreshUmeToken, mutationFn: refreshUmeToken,
@ -103,8 +111,9 @@ export function UmePage() {
const subscriptionStatusQuery = useQuery({ const subscriptionStatusQuery = useQuery({
queryKey: queryKeys.umeAlarmSubscription, queryKey: queryKeys.umeAlarmSubscription,
queryFn: fetchUmeAlarmSubscriptionStatus, queryFn: fetchUmeAlarmSubscriptionStatus,
staleTime: 3000, staleTime: 10_000,
refetchInterval: 5000, refetchInterval: 15_000,
refetchIntervalInBackground: false,
}); });
const keyAlertMonitorQuery = useQuery({ const keyAlertMonitorQuery = useQuery({
queryKey: queryKeys.umeKeyAlertMonitor( queryKey: queryKeys.umeKeyAlertMonitor(
@ -122,8 +131,9 @@ export function UmePage() {
enabled: keyAlertFilterEnabled, enabled: keyAlertFilterEnabled,
matchType: keyAlertFilterMatchType, matchType: keyAlertFilterMatchType,
}), }),
staleTime: 3000, staleTime: keyAlertPanelOpen ? 3000 : 15_000,
refetchInterval: 5000, refetchInterval: keyAlertPanelOpen ? 5000 : 20_000,
refetchIntervalInBackground: false,
}); });
const keyAlertNeTypesQuery = useQuery({ const keyAlertNeTypesQuery = useQuery({
queryKey: queryKeys.umeInventoryNeTypes, queryKey: queryKeys.umeInventoryNeTypes,
@ -981,6 +991,7 @@ export function UmePage() {
</div> </div>
</div> </div>
<div className="ops-detail-modal__scroll ops-detail-modal__scroll--flow">
<div className="ume-modal-form"> <div className="ume-modal-form">
<div className="ops-detail-modal__toolbar filter-inline"> <div className="ops-detail-modal__toolbar filter-inline">
<label className="muted">{t("ume.keyAlert.matchType")}</label> <label className="muted">{t("ume.keyAlert.matchType")}</label>
@ -1148,7 +1159,6 @@ export function UmePage() {
</select> </select>
</div> </div>
<div className="ops-detail-modal__scroll">
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
<thead> <thead>
@ -1239,7 +1249,6 @@ export function UmePage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div className="ops-detail-modal__foot"> <div className="ops-detail-modal__foot">
<span className="muted"> <span className="muted">
@ -1280,6 +1289,7 @@ export function UmePage() {
</div> </div>
</div> </div>
</div> </div>
</div>
) : null} ) : null}
{syncStatusPanelOpen ? ( {syncStatusPanelOpen ? (

View file

@ -1,4 +1,6 @@
import { import {
lazy,
Suspense,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@ -10,7 +12,7 @@ import {
} from "react"; } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal"; import type { WebTerminalHandle } from "../components/WebTerminal";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import { import {
@ -44,6 +46,10 @@ import {
type KeywordRule, type KeywordRule,
} from "../utils/webcrtKeywordHighlight"; } from "../utils/webcrtKeywordHighlight";
/** xterm is large — only pull it when a session pane actually mounts. */
const WebTerminal = lazy(() =>
import("../components/WebTerminal").then((m) => ({ default: m.WebTerminal })),
);
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions"; const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions";
const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs"; const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs";
@ -2385,6 +2391,13 @@ export function WebcrtPage() {
</div> </div>
) : null} ) : null}
{mountTerminal ? ( {mountTerminal ? (
<Suspense
fallback={
<div className="webcrt-main__placeholder" role="status">
{t("common.refreshing")}
</div>
}
>
<WebTerminal <WebTerminal
key={`${tab.key}:${tab.termEpoch}`} key={`${tab.key}:${tab.termEpoch}`}
ref={(handle) => { ref={(handle) => {
@ -2500,6 +2513,7 @@ export function WebcrtPage() {
} }
}} }}
/> />
</Suspense>
) : null} ) : null}
</> </>
) : null} ) : null}

View file

@ -89,15 +89,18 @@ export function WorkbenchPage() {
const metricsQuery = useQuery({ const metricsQuery = useQuery({
queryKey: ["runtimeMetrics"], queryKey: ["runtimeMetrics"],
queryFn: fetchRuntimeMetrics, queryFn: fetchRuntimeMetrics,
refetchInterval: 5000, staleTime: 10_000,
staleTime: 2000, refetchInterval: 15_000,
refetchIntervalInBackground: false,
}); });
const opsQuery = useQuery({ const opsQuery = useQuery({
queryKey: ["opsTasks"], queryKey: ["opsTasks"],
queryFn: fetchOpsTasks, queryFn: fetchOpsTasks,
refetchInterval: 4000, staleTime: 10_000,
staleTime: 1500, // Share AppLayout cache; speed up only while something is active.
refetchInterval: (q) => ((q.state.data?.active ?? 0) > 0 ? 4000 : 20_000),
refetchIntervalInBackground: false,
}); });
const visibleBySection = useMemo(() => { const visibleBySection = useMemo(() => {

View file

@ -159,15 +159,25 @@ export function PortTrafficPage() {
const dashQuery = useQuery({ const dashQuery = useQuery({
queryKey: queryKeys.portTrafficDashboard, queryKey: queryKeys.portTrafficDashboard,
queryFn: fetchPortTrafficDashboard, queryFn: fetchPortTrafficDashboard,
staleTime: 2000, staleTime: 5000,
refetchInterval: POLL_MS, refetchInterval: (q) => {
const d = q.state.data;
const running = Number(d?.running_device_count ?? d?.running_task_count ?? 0);
return running > 0 ? POLL_MS : 15_000;
},
refetchIntervalInBackground: false,
}); });
const devicesQuery = useQuery({ const devicesQuery = useQuery({
queryKey: queryKeys.portTrafficDevices(listPage), queryKey: queryKeys.portTrafficDevices(listPage),
queryFn: () => fetchPortTrafficDevices({ page: listPage, pageSize: 10 }), queryFn: () => fetchPortTrafficDevices({ page: listPage, pageSize: 10 }),
staleTime: 2000, staleTime: 5000,
refetchInterval: POLL_MS, refetchInterval: (q) => {
const items = q.state.data?.items || [];
const running = items.some((x) => x.status === "running" || Boolean(x.collect_running));
return running ? POLL_MS : 15_000;
},
refetchIntervalInBackground: false,
}); });
const nesQuery = useQuery({ const nesQuery = useQuery({

View file

@ -0,0 +1,503 @@
import { createContext, memo, useContext, type Dispatch, type SetStateAction } from "react";
import {
ReactFlow,
Background,
BackgroundVariant,
Controls,
ControlButton,
MiniMap,
Handle,
Position,
ConnectionLineType,
ConnectionMode,
SelectionMode,
applyNodeChanges,
applyEdgeChanges,
type Connection,
type Edge,
type Node,
type NodeChange,
type EdgeChange,
type NodeProps,
type ReactFlowInstance,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { WorldScatterLayer, type WorldScatterPoint } from "./WorldScatterLayer";
import { ParallelEdge } from "./ParallelEdge";
import { isAggregateEdgeId } from "./linkDisplay";
import type { ToolMode, ToolModeBehavior } from "./toolMode";
import type { TopologyViewRole, TopologyWorldTransform } from "../../types";
export type NeNodeData = {
label: string;
managed_ne_id: string;
ume_ne_id: string;
ne_ip: string;
vendor: string;
connect_status: string;
managed_source?: string;
kind?: "ne" | "region" | "layer";
folder_id?: string;
view_id?: string;
role?: TopologyViewRole | string;
subtitle?: string;
node_count?: number;
};
export type TopoDisplayOpts = {
hideIp: boolean;
hideVendor: boolean;
hidePorts: boolean;
connectMode: boolean;
showPlaceholderBadge: boolean;
worldVisualLod: "dot" | "pin" | "full";
};
const TopoDisplayContext = createContext<TopoDisplayOpts>({
hideIp: true,
hideVendor: true,
hidePorts: true,
connectMode: false,
showPlaceholderBadge: false,
worldVisualLod: "full",
});
const SEP = " / ";
function nodeIconTone(vendor: string, managedNeId: string, umeNeId = ""): string {
const hasManaged = Boolean(String(managedNeId || "").trim());
const hasUme = Boolean(String(umeNeId || "").trim());
if (!hasManaged && !hasUme) return "gray";
const v = String(vendor || "").trim().toLowerCase();
if (!v || v === "other" || v === "unknown" || v === "generic") {
return hasUme ? "zte" : "gray";
}
if (v.includes("cisco")) return "cisco";
if (v.includes("huawei")) return "huawei";
if (v.includes("zte")) return "zte";
if (v.includes("juniper")) return "juniper";
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
if (v.includes("ericsson")) return "ericsson";
if (v.includes("h3c") || v.includes("comware")) return "h3c";
if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie";
if (v.includes("mikrotik")) return "mikrotik";
return hasUme ? "zte" : "gray";
}
function isPlaceholderSource(source: string | undefined, neIp: string): boolean {
const src = String(source || "").trim().toLowerCase();
if (src === "lldp" || src === "topology") return true;
return !String(neIp || "").trim() && Boolean(src);
}
export { isPlaceholderSource };
function RouterIcon() {
return (
<span className="topo-node__icon" aria-hidden="true">
<img className="topo-node__icon-art" src="/topo/ne-router.png" alt="" draggable={false} />
<span className="topo-node__icon-tint" />
</span>
);
}
function RegionCanvasIcon() {
return (
<span className="topo-node__icon topo-node__icon--region" aria-hidden="true">
<img className="topo-node__icon-art" src="/topo/region-building.png" alt="" draggable={false} />
</span>
);
}
function FullscreenIcon({ exit }: { exit?: boolean }) {
if (exit) {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm12 0h-2v3h-3v2h5v-5zM7 5h3V3H5v5h2V5zm10 0v3h2V3h-5v2h3z"
/>
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
/>
</svg>
);
}
const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
const { hideIp, hideVendor, connectMode, showPlaceholderBadge, worldVisualLod } =
useContext(TopoDisplayContext);
const isRegion = data.kind === "region";
const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip);
const showBadge = placeholder && showPlaceholderBadge;
const name = data.label || (!hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
const secondary = isRegion
? []
: [
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
hideVendor || !data.vendor ? "" : data.vendor,
].filter(Boolean);
if (!isRegion && worldVisualLod === "dot") {
return (
<div
className={`topo-node topo-node--dot topo-node--${tone}${selected ? " is-selected" : ""}`}
title={name}
>
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
<span className="topo-node__pixel" aria-hidden="true" />
</div>
);
}
if (!isRegion && worldVisualLod === "pin") {
return (
<div
className={`topo-node topo-node--pin topo-node--${tone}${selected ? " is-selected" : ""}`}
title={name}
>
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
<span className="topo-node__pin" aria-hidden="true" />
</div>
);
}
return (
<div
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
connectMode && !isRegion ? " is-connect-mode" : ""
}${showBadge ? " is-placeholder" : ""}${isRegion ? " is-region" : ""}`}
title={isRegion ? name : showBadge ? data.managed_source || "placeholder" : name}
>
<div className="topo-node__glyph">
<Handle
type="target"
position={Position.Left}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode && !isRegion}
/>
<Handle
type="source"
position={Position.Right}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode && !isRegion}
/>
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
{showBadge ? (
<span className="topo-node__badge" aria-hidden>
{String(data.managed_source || "ph").slice(0, 4)}
</span>
) : null}
</div>
<div className="topo-node__caption">
<span className="topo-node__caption-name">{name}</span>
{secondary.length ? (
<span className="topo-node__caption-meta">{secondary.join(SEP)}</span>
) : null}
</div>
</div>
);
});
const nodeTypes = { neNode: NeNode };
const edgeTypes = { topoParallel: ParallelEdge };
const SNAP_GRID: [number, number] = [16, 16];
const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
function canvasDotColor(bg: string): string {
const hex = String(bg || "").replace("#", "");
if (hex.length !== 6) return "#93c5fd";
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.55 ? "#93c5fd" : "#64748b";
}
function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): {
x: number;
y: number;
width: number;
height: number;
} | null {
if (!wt) return null;
const scale = Number(wt.scale) || 1;
const width = Math.max(1, (Number(wt.full_max_x) - Number(wt.full_min_x)) * scale);
const height = Math.max(1, (Number(wt.full_max_y) - Number(wt.full_min_y)) * scale);
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
return { x: 0, y: 0, width, height };
}
type CtxMenuLike =
| { kind: "node"; id: string; x: number; y: number }
| { kind: "edge"; id: string; x: number; y: number }
| { kind: "selection"; x: number; y: number }
| { kind: "pane"; x: number; y: number; flowX: number; flowY: number };
export type TopologyReactFlowViewProps = {
displayOpts: TopoDisplayOpts;
nodes: Node<NeNodeData>[];
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
edges: Edge[];
setEdges: Dispatch<SetStateAction<Edge[]>>;
displayEdges: Edge[];
searchHitIds: string[];
isWorldFlatCanvas: boolean;
worldVisualLod: "dot" | "pin" | "full";
toolBehavior: ToolModeBehavior;
toolMode: ToolMode;
snapToGrid: boolean;
canvasBg: string;
worldScatter: WorldScatterPoint[];
showWorldScatter: boolean;
fullscreen: boolean;
connectHint: string;
fullscreenLabel: string;
exitFullscreenLabel: string;
worldTransform: TopologyWorldTransform | null | undefined;
nodesRef: { current: Node<NeNodeData>[] };
rfRef: { current: ReactFlowInstance<Node<NeNodeData>, Edge> | null };
pendingFitRef: { current: boolean };
markDirty: () => void;
pushHistory: () => void;
onConnect: (connection: Connection) => void;
isValidConnection: (connection: Connection | Edge) => boolean;
onNodeClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
onNodeDoubleClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
focusEdge: (id: string) => void;
focusNode: (id: string, additive: boolean) => void;
clearSelection: () => void;
setCtxMenu: (menu: CtxMenuLike | null) => void;
placeCtxMenu: (
x: number,
y: number,
size?: { w?: number; h?: number },
) => { x: number; y: number };
selectedNodeIds: string[];
closeCtxMenu: () => void;
setCanvasZoom: (z: number) => void;
scheduleFlatViewportRefresh: () => void;
toggleFullscreen: () => void | Promise<void>;
};
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {
const {
displayOpts,
nodes,
setNodes,
setEdges,
displayEdges,
searchHitIds,
isWorldFlatCanvas,
worldVisualLod,
toolBehavior,
toolMode,
snapToGrid,
canvasBg,
worldScatter,
showWorldScatter,
fullscreen,
connectHint,
fullscreenLabel,
exitFullscreenLabel,
worldTransform,
nodesRef,
rfRef,
pendingFitRef,
markDirty,
pushHistory,
onConnect,
isValidConnection,
onNodeClick,
onNodeDoubleClick,
focusEdge,
focusNode,
clearSelection,
setCtxMenu,
placeCtxMenu,
selectedNodeIds,
closeCtxMenu,
setCanvasZoom,
scheduleFlatViewportRefresh,
toggleFullscreen,
} = props;
return (
<TopoDisplayContext.Provider value={displayOpts}>
<ReactFlow
nodes={
isWorldFlatCanvas && worldVisualLod !== "full"
? []
: nodes.map((n) => {
const isRegion = n.data.kind === "region";
const lod = isWorldFlatCanvas ? worldVisualLod : "full";
const sized =
!isRegion && lod === "dot"
? { ...n, width: 6, height: 6 }
: !isRegion && lod === "pin"
? { ...n, width: 14, height: 14 }
: n;
return searchHitIds.includes(n.id)
? { ...sized, selected: true, className: "is-search-hit" }
: { ...sized, className: undefined };
})
}
edges={isWorldFlatCanvas && worldVisualLod !== "full" ? [] : displayEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onlyRenderVisibleElements={isWorldFlatCanvas ? worldVisualLod === "dot" : true}
connectionMode={ConnectionMode.Loose}
connectionLineType={ConnectionLineType.Straight}
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }}
defaultEdgeOptions={{ type: "straight", labelShowBg: false }}
proOptions={{ hideAttribution: true }}
minZoom={isWorldFlatCanvas ? 0.002 : 0.05}
maxZoom={isWorldFlatCanvas ? 12 : 4}
nodesDraggable={
isWorldFlatCanvas
? worldVisualLod === "full" && toolBehavior.nodesDraggable
: toolBehavior.nodesDraggable
}
nodesConnectable={toolBehavior.nodesConnectable}
elementsSelectable={toolBehavior.elementsSelectable}
panOnDrag={toolBehavior.panOnDrag}
selectionOnDrag={toolBehavior.selectionOnDrag}
panOnScroll={toolBehavior.panOnScroll}
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode="Shift"
snapToGrid={snapToGrid}
snapGrid={SNAP_GRID}
onNodeDragStart={() => {
pushHistory();
}}
onNodesChange={(changes: NodeChange<Node<NeNodeData>>[]) => {
if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) {
markDirty();
}
setNodes((nds) => applyNodeChanges(changes, nds));
}}
onEdgesChange={(changes: EdgeChange<Edge>[]) => {
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();
}
if (physical.length) setEdges((eds) => applyEdgeChanges(physical, eds));
}}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onEdgeClick={(_e, edge) => {
setCtxMenu(null);
focusEdge(edge.id);
}}
onPaneClick={() => {
setCtxMenu(null);
clearSelection();
}}
onNodeContextMenu={(e, node) => {
e.preventDefault();
const multi = selectedNodeIds.length > 1 && selectedNodeIds.includes(node.id);
if (!multi) focusNode(node.id, false);
const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu(multi ? { kind: "selection", ...pos } : { kind: "node", id: node.id, ...pos });
}}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
focusEdge(edge.id);
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 260, h: 280 });
setCtxMenu({ kind: "edge", id: edge.id, ...pos });
}}
onSelectionContextMenu={(e) => {
e.preventDefault();
const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu({ kind: "selection", ...pos });
}}
onPaneContextMenu={(e) => {
e.preventDefault();
if (!rfRef.current) return;
const flow = rfRef.current.screenToFlowPosition({
x: e.clientX,
y: e.clientY,
});
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 180, h: 100 });
setCtxMenu({
kind: "pane",
...pos,
flowX: flow.x,
flowY: flow.y,
});
}}
onMoveStart={closeCtxMenu}
onMove={(_e, vp) => {
if (isWorldFlatCanvas) setCanvasZoom(vp.zoom);
}}
onMoveEnd={(_e, vp) => {
if (!isWorldFlatCanvas) return;
setCanvasZoom(vp.zoom);
scheduleFlatViewportRefresh();
}}
onInit={(inst) => {
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
if (pendingFitRef.current) {
pendingFitRef.current = false;
window.requestAnimationFrame(() => {
const bounds = isWorldFlatCanvas ? worldDisplayBounds(worldTransform) : null;
if (bounds) {
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
} else if (nodesRef.current.length > 0) {
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
}
setCanvasZoom(inst.getZoom());
});
}
}}
fitView={false}
deleteKeyCode={null}
edgesFocusable
>
<WorldScatterLayer
points={worldScatter}
mode={worldVisualLod === "pin" ? "pin" : "dot"}
visible={showWorldScatter}
/>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color={canvasDotColor(canvasBg)}
bgColor={canvasBg}
/>
{toolMode === "connect" ? (
<div className="topo-mode-hint" role="status">
{connectHint}
</div>
) : null}
<Controls showInteractive>
<ControlButton
className="topo-fs-control"
onClick={() => void toggleFullscreen()}
title={fullscreen ? exitFullscreenLabel : fullscreenLabel}
aria-label={fullscreen ? exitFullscreenLabel : fullscreenLabel}
>
<FullscreenIcon exit={fullscreen} />
</ControlButton>
</Controls>
{!isWorldFlatCanvas ? <MiniMap pannable zoomable /> : null}
</ReactFlow>
</TopoDisplayContext.Provider>
);
}

View file

@ -1,4 +1,3 @@
import dagre from "dagre";
import type { Edge, Node } from "@xyflow/react"; import type { Edge, Node } from "@xyflow/react";
export type LayoutKind = "hierarchical-tb" | "hierarchical-lr" | "force" | "grid" | "radial"; export type LayoutKind = "hierarchical-tb" | "hierarchical-lr" | "force" | "grid" | "radial";
@ -8,6 +7,19 @@ const NODE_H = 72;
type XY = { x: number; y: number }; type XY = { x: number; y: number };
type DagreNS = typeof import("dagre");
let dagreMod: DagreNS | null = null;
async function loadDagre(): Promise<DagreNS> {
if (!dagreMod) {
const mod = await import("dagre");
// CJS typings (`export =`) may surface as default or namespace under Vite.
dagreMod = ((mod as { default?: DagreNS }).default ?? mod) as DagreNS;
}
return dagreMod;
}
function applyPositions<T extends Record<string, unknown>>( function applyPositions<T extends Record<string, unknown>>(
nodes: Node<T>[], nodes: Node<T>[],
positions: Map<string, XY>, positions: Map<string, XY>,
@ -19,11 +31,12 @@ function applyPositions<T extends Record<string, unknown>>(
}); });
} }
function layoutDagre<T extends Record<string, unknown>>( async function layoutDagre<T extends Record<string, unknown>>(
nodes: Node<T>[], nodes: Node<T>[],
edges: Edge[], edges: Edge[],
rankdir: "TB" | "LR", rankdir: "TB" | "LR",
): Node<T>[] { ): Promise<Node<T>[]> {
const dagre = await loadDagre();
const g = new dagre.graphlib.Graph(); const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({})); g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir, nodesep: 48, ranksep: 72, marginx: 24, marginy: 24 }); g.setGraph({ rankdir, nodesep: 48, ranksep: 72, marginx: 24, marginy: 24 });
@ -150,12 +163,13 @@ function layoutRadial<T extends Record<string, unknown>>(nodes: Node<T>[]): Node
}); });
} }
export function layoutGraph<T extends Record<string, unknown>>( /** dagre is loaded on first hierarchical layout only — not with the topology page chunk. */
export async function layoutGraph<T extends Record<string, unknown>>(
nodes: Node<T>[], nodes: Node<T>[],
edges: Edge[], edges: Edge[],
kind: LayoutKind, kind: LayoutKind,
opts?: { onlyIds?: Set<string> }, opts?: { onlyIds?: Set<string> },
): Node<T>[] { ): Promise<Node<T>[]> {
const only = opts?.onlyIds; const only = opts?.onlyIds;
const targets = only && only.size > 0 ? nodes.filter((n) => only.has(n.id)) : nodes; const targets = only && only.size > 0 ? nodes.filter((n) => only.has(n.id)) : nodes;
const targetIds = new Set(targets.map((n) => n.id)); const targetIds = new Set(targets.map((n) => n.id));
@ -164,7 +178,7 @@ export function layoutGraph<T extends Record<string, unknown>>(
let laid: Node<T>[]; let laid: Node<T>[];
switch (kind) { switch (kind) {
case "hierarchical-lr": case "hierarchical-lr":
laid = layoutDagre(targets, subEdges, "LR"); laid = await layoutDagre(targets, subEdges, "LR");
break; break;
case "force": case "force":
laid = layoutForce(targets, subEdges); laid = layoutForce(targets, subEdges);
@ -177,7 +191,7 @@ export function layoutGraph<T extends Record<string, unknown>>(
break; break;
case "hierarchical-tb": case "hierarchical-tb":
default: default:
laid = layoutDagre(targets, subEdges, "TB"); laid = await layoutDagre(targets, subEdges, "TB");
break; break;
} }

View file

@ -4,6 +4,21 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: {
rollupOptions: {
output: {
// Keep heavy optional libs in stable vendor chunks so route opens
// don't re-download/parse them mixed into page modules.
manualChunks(id) {
if (!id.includes('node_modules')) return
if (id.includes('@xterm')) return 'vendor-xterm'
if (id.includes('@xyflow') || id.includes('dagre')) return 'vendor-topology'
if (id.includes('uplot')) return 'vendor-uplot'
if (id.includes('@tanstack')) return 'vendor-query'
},
},
},
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {