mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
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:
parent
a0170907e4
commit
9bcb8ee0e3
11 changed files with 906 additions and 663 deletions
BIN
web/public/login-earth-night-bright.webp
Normal file
BIN
web/public/login-earth-night-bright.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
503
web/src/pages/topology/TopologyReactFlowView.tsx
Normal file
503
web/src/pages/topology/TopologyReactFlowView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue