mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 11:05:53 +08:00
Refactor web UI: feature-first structure, tokenized styles, modern blue theme.
Structure
- pages/ -> features/<domain>/{Page, components, hooks, lib}; 95 files moved,
333 imports rewritten, verified with tsc.
- services/api.ts -> services/api/<domain>.ts (250 exports preserved),
types.ts -> types/<domain>.ts, i18n zh/en -> locales/<lang>/<namespace>.ts
(2696 messages deep-equal before/after).
- ESLint rule: shared layers must not import features/.
Styles
- index.css (16k lines) split losslessly into styles/{base,layout,components,
pages,legacy,theme}; compiled CSS was byte-identical before tokenizing.
- ~930 hard-coded colors replaced by --nx-* tokens (theme/tokens.css); WebCRT
off-palette colors unified; fixed dark-on-dark text introduced by mapping.
- theme/theme.css: modern blue design language (rounded shapes, gradients,
unified buttons/fields/tables/modals), form-grid alignment fix, flat nav list
without tree indent, soft selected state instead of left accent bar.
Docs: WEB.md documents code structure and style rules.
Not touched: the large Biz*/Webcrt/Topology page components (to be split later).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b45d6abe3
commit
0d894efe5e
196 changed files with 26947 additions and 28091 deletions
73
web/src/features/topology/lib/alarmOverlay.ts
Normal file
73
web/src/features/topology/lib/alarmOverlay.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/** Alarm severity helpers for topology overlay (UME current alarms). */
|
||||
|
||||
export type AlarmSeverity =
|
||||
| "critical"
|
||||
| "major"
|
||||
| "minor"
|
||||
| "warning"
|
||||
| "indeterminate"
|
||||
| "cleared"
|
||||
| "unknown";
|
||||
|
||||
export type NodeAlarmSummary = {
|
||||
severity: AlarmSeverity;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const SEV_RANK: Record<string, number> = {
|
||||
critical: 60,
|
||||
major: 50,
|
||||
minor: 40,
|
||||
warning: 30,
|
||||
indeterminate: 20,
|
||||
unknown: 10,
|
||||
cleared: 0,
|
||||
};
|
||||
|
||||
export function normalizeAlarmSeverity(raw: string | undefined | null): AlarmSeverity {
|
||||
const s = String(raw || "").trim().toLowerCase();
|
||||
if (!s) return "unknown";
|
||||
if (s.includes("critical") || s === "5") return "critical";
|
||||
if (s.includes("major") || s === "4") return "major";
|
||||
if (s.includes("minor") || s === "3") return "minor";
|
||||
if (s.includes("warning") || s.includes("warn") || s === "2") return "warning";
|
||||
if (s.includes("indeterminate") || s === "1") return "indeterminate";
|
||||
if (s.includes("clear") || s === "0") return "cleared";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function severityRank(sev: string): number {
|
||||
return SEV_RANK[normalizeAlarmSeverity(sev)] ?? 0;
|
||||
}
|
||||
|
||||
export function worseSeverity(a: string, b: string): AlarmSeverity {
|
||||
return severityRank(a) >= severityRank(b) ? normalizeAlarmSeverity(a) : normalizeAlarmSeverity(b);
|
||||
}
|
||||
|
||||
export function normalizeHostKey(value: string | undefined | null): string {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** IPv4 / IPv6-ish — safe for exact alarm join. */
|
||||
export function looksLikeIp(value: string | undefined | null): boolean {
|
||||
const s = normalizeHostKey(value);
|
||||
if (!s) return false;
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return true;
|
||||
// Compact check: colon + hex/digits (avoid matching short names).
|
||||
return s.includes(":") && /^[0-9a-f:%.]+$/i.test(s) && s.length >= 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host/name keys used for alarm overlay join.
|
||||
* Reject short bare labels (e.g. "r1") that collide across sites.
|
||||
*/
|
||||
export function isStrongHostKey(value: string | undefined | null): boolean {
|
||||
const k = normalizeHostKey(value);
|
||||
if (!k) return false;
|
||||
if (looksLikeIp(k)) return true;
|
||||
if (k.includes(".")) return true; // FQDN / dotted inventory name
|
||||
return k.length >= 8;
|
||||
}
|
||||
33
web/src/features/topology/lib/constants.ts
Normal file
33
web/src/features/topology/lib/constants.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/** Flat world-map canvas (LOD / scatter). Temporarily offline in the UI. */
|
||||
export const WORLD_MAP_ENABLED = false;
|
||||
|
||||
export const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
|
||||
export const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
|
||||
export const SIDEBAR_WIDTH_KEY = "netx.topology.sidebarWidth";
|
||||
export const SIDEBAR_WIDTH_DEFAULT = 260;
|
||||
export const SIDEBAR_WIDTH_MIN = 180;
|
||||
export const SIDEBAR_WIDTH_MAX = 560;
|
||||
|
||||
export const UNDO_MAX = 40;
|
||||
export const PALETTE_DND = "application/x-netx-topo-palette";
|
||||
export const SEP = " / ";
|
||||
|
||||
export const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
|
||||
export const WORLD_LOCATE_ZOOM = 0.22;
|
||||
export const WORLD_LOCATE_HALF = 12000;
|
||||
export const WORLD_FLAT_ACCUM_CAP = 8000;
|
||||
|
||||
export const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
|
||||
export const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
|
||||
export const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched.v2";
|
||||
export const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors.v2";
|
||||
export const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth";
|
||||
export const SHOW_PLACEHOLDER_BADGE_KEY = "netx.topology.showPlaceholderBadge";
|
||||
export const SHOW_CONNECT_STATUS_KEY = "netx.topology.showConnectStatus";
|
||||
export const SHOW_ALARM_OVERLAY_KEY = "netx.topology.showAlarmOverlay";
|
||||
export const SHOW_LEVEL_COLORS_KEY = "netx.topology.showLevelColors";
|
||||
export const CANVAS_BG_KEY = "netx.topology.canvasBg";
|
||||
export const DEFAULT_CANVAS_BG = "#0f172a";
|
||||
export const LEGACY_CANVAS_BG = "#dbeafe";
|
||||
export const LABEL_COLORS_KEY = "netx.topology.labelColors";
|
||||
export const VENDOR_COLORS_KEY = "netx.topology.vendorColors";
|
||||
146
web/src/features/topology/lib/displayPrefs.ts
Normal file
146
web/src/features/topology/lib/displayPrefs.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
AUTO_LAYOUT_DISCOVER_KEY,
|
||||
CANVAS_BG_KEY,
|
||||
DEFAULT_CANVAS_BG,
|
||||
LABEL_COLORS_KEY,
|
||||
LEGACY_CANVAS_BG,
|
||||
VENDOR_COLORS_KEY,
|
||||
} from "./constants";
|
||||
|
||||
export const VENDOR_TONE_KEYS = [
|
||||
"cisco",
|
||||
"huawei",
|
||||
"zte",
|
||||
"juniper",
|
||||
"nokia",
|
||||
"ericsson",
|
||||
"h3c",
|
||||
"ruijie",
|
||||
"mikrotik",
|
||||
"gray",
|
||||
] as const;
|
||||
|
||||
export type VendorToneKey = (typeof VENDOR_TONE_KEYS)[number];
|
||||
export type VendorColors = Record<VendorToneKey, string>;
|
||||
export type LabelColors = { name: string; edgeLabel: string };
|
||||
|
||||
export const DEFAULT_LABEL_COLORS: LabelColors = {
|
||||
name: "#f1f5f9",
|
||||
edgeLabel: "#e2e8f0",
|
||||
};
|
||||
|
||||
export const DEFAULT_VENDOR_COLORS: VendorColors = {
|
||||
cisco: "#049fd9",
|
||||
huawei: "#cf0a2c",
|
||||
zte: "#0091da",
|
||||
juniper: "#84b135",
|
||||
nokia: "#124191",
|
||||
ericsson: "#1e3a5f",
|
||||
h3c: "#7ac143",
|
||||
ruijie: "#7c3aed",
|
||||
mikrotik: "#ea580c",
|
||||
gray: "#94a3b8",
|
||||
};
|
||||
|
||||
export function isHexColor(value: string): boolean {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value);
|
||||
}
|
||||
|
||||
export function loadCanvasBg(): string {
|
||||
try {
|
||||
const raw = String(localStorage.getItem(CANVAS_BG_KEY) || "").trim().toLowerCase();
|
||||
if (raw === LEGACY_CANVAS_BG) {
|
||||
persistCanvasBg(DEFAULT_CANVAS_BG);
|
||||
return DEFAULT_CANVAS_BG;
|
||||
}
|
||||
if (isHexColor(raw)) return raw;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return DEFAULT_CANVAS_BG;
|
||||
}
|
||||
|
||||
export function persistCanvasBg(value: string) {
|
||||
try {
|
||||
localStorage.setItem(CANVAS_BG_KEY, value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadLabelColors(): LabelColors {
|
||||
try {
|
||||
const raw = localStorage.getItem(LABEL_COLORS_KEY);
|
||||
if (!raw) return { ...DEFAULT_LABEL_COLORS };
|
||||
const parsed = JSON.parse(raw) as Partial<LabelColors>;
|
||||
return {
|
||||
name: isHexColor(String(parsed.name || "")) ? String(parsed.name).toLowerCase() : DEFAULT_LABEL_COLORS.name,
|
||||
edgeLabel: isHexColor(String(parsed.edgeLabel || ""))
|
||||
? String(parsed.edgeLabel).toLowerCase()
|
||||
: DEFAULT_LABEL_COLORS.edgeLabel,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_LABEL_COLORS };
|
||||
}
|
||||
}
|
||||
|
||||
export function persistLabelColors(value: LabelColors) {
|
||||
try {
|
||||
localStorage.setItem(LABEL_COLORS_KEY, JSON.stringify(value));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadVendorColors(): VendorColors {
|
||||
try {
|
||||
const raw = localStorage.getItem(VENDOR_COLORS_KEY);
|
||||
if (!raw) return { ...DEFAULT_VENDOR_COLORS };
|
||||
const parsed = JSON.parse(raw) as Partial<VendorColors>;
|
||||
const out = { ...DEFAULT_VENDOR_COLORS };
|
||||
for (const key of VENDOR_TONE_KEYS) {
|
||||
const c = String(parsed?.[key] || "").trim().toLowerCase();
|
||||
if (isHexColor(c)) out[key] = c;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return { ...DEFAULT_VENDOR_COLORS };
|
||||
}
|
||||
}
|
||||
|
||||
export function persistVendorColors(value: VendorColors) {
|
||||
try {
|
||||
localStorage.setItem(VENDOR_COLORS_KEY, JSON.stringify(value));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadBoolFlag(key: string, defaultValue: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return defaultValue;
|
||||
return raw === "1" || raw === "true";
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistBoolFlag(key: string, value: boolean) {
|
||||
try {
|
||||
localStorage.setItem(key, value ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAutoLayoutAfterDiscover(): boolean {
|
||||
return loadBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, false);
|
||||
}
|
||||
|
||||
export function persistAutoLayoutAfterDiscover(value: boolean) {
|
||||
persistBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, value);
|
||||
}
|
||||
|
||||
export type { CSSProperties };
|
||||
118
web/src/features/topology/lib/edgeStyle.ts
Normal file
118
web/src/features/topology/lib/edgeStyle.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import type { Edge } from "@xyflow/react";
|
||||
import type { LinkEdgeData } from "./linkDisplay";
|
||||
import { EDGE_DEFAULTS_KEY } from "./constants";
|
||||
|
||||
export type EdgeLineStyle = "solid" | "dashed" | "dotted";
|
||||
export type EdgeSourceKind = "manual" | "ume" | "discovered" | "stale";
|
||||
|
||||
export type EdgeDefaultStyle = {
|
||||
stroke_color: string;
|
||||
stroke_width: number;
|
||||
line_style: EdgeLineStyle;
|
||||
};
|
||||
|
||||
export type EdgeDefaults = Record<EdgeSourceKind, EdgeDefaultStyle>;
|
||||
export type EdgeStyleData = LinkEdgeData;
|
||||
|
||||
const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
|
||||
ume: { stroke_color: "#2563eb", stroke_width: 2, line_style: "solid" },
|
||||
discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" },
|
||||
stale: { stroke_color: "#dc2626", stroke_width: 2, line_style: "dashed" },
|
||||
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
|
||||
};
|
||||
|
||||
export function sourceKind(source: string): EdgeSourceKind {
|
||||
const src = (source || "manual").toLowerCase();
|
||||
if (src === "stale" || src === "missing") return "stale";
|
||||
if (src === "ume") return "ume";
|
||||
if (src === "lldp" || src === "cdp") return "discovered";
|
||||
return "manual";
|
||||
}
|
||||
|
||||
export function loadEdgeDefaults(): EdgeDefaults {
|
||||
try {
|
||||
const raw = localStorage.getItem(EDGE_DEFAULTS_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
|
||||
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
|
||||
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
|
||||
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
|
||||
};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<EdgeDefaults>;
|
||||
const pick = (kind: EdgeSourceKind): EdgeDefaultStyle => {
|
||||
const base = BUILTIN_EDGE_DEFAULTS[kind];
|
||||
const cur = parsed?.[kind];
|
||||
const color = String(cur?.stroke_color || base.stroke_color).trim() || base.stroke_color;
|
||||
const width = Math.max(1, Math.min(12, Number(cur?.stroke_width || base.stroke_width) || base.stroke_width));
|
||||
const line = String(cur?.line_style || base.line_style).toLowerCase();
|
||||
const line_style: EdgeLineStyle =
|
||||
line === "dashed" || line === "dotted" || line === "solid" ? line : base.line_style;
|
||||
return { stroke_color: color, stroke_width: width, line_style };
|
||||
};
|
||||
return {
|
||||
ume: pick("ume"),
|
||||
discovered: pick("discovered"),
|
||||
stale: pick("stale"),
|
||||
manual: pick("manual"),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
|
||||
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
|
||||
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
|
||||
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function persistEdgeDefaults(next: EdgeDefaults) {
|
||||
try {
|
||||
localStorage.setItem(EDGE_DEFAULTS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function dashForLineStyle(lineStyle: string): string | undefined {
|
||||
const s = (lineStyle || "").trim().toLowerCase();
|
||||
if (s === "dashed") return "6 4";
|
||||
if (s === "dotted") return "2 2";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function edgeStyleBySource(
|
||||
source: string,
|
||||
defaults: EdgeDefaults,
|
||||
): { stroke: string; strokeDasharray?: string; strokeWidth: number } {
|
||||
const d = defaults[sourceKind(source)];
|
||||
return {
|
||||
stroke: d.stroke_color,
|
||||
strokeWidth: d.stroke_width,
|
||||
strokeDasharray: dashForLineStyle(d.line_style),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveEdgeStyle(
|
||||
data: EdgeStyleData | undefined,
|
||||
defaults: EdgeDefaults,
|
||||
): { stroke: string; strokeDasharray?: string; strokeWidth: number } {
|
||||
const base = edgeStyleBySource(data?.source || "manual", defaults);
|
||||
const color = String(data?.stroke_color || "").trim();
|
||||
const width = Number(data?.stroke_width || 0);
|
||||
const line = String(data?.line_style || "").trim().toLowerCase();
|
||||
return {
|
||||
stroke: color || base.stroke,
|
||||
strokeWidth: width > 0 ? width : base.strokeWidth,
|
||||
strokeDasharray: line ? dashForLineStyle(line) : base.strokeDasharray,
|
||||
};
|
||||
}
|
||||
|
||||
export function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge {
|
||||
const data = (edge.data || {}) as EdgeStyleData;
|
||||
const style = resolveEdgeStyle(data, defaults);
|
||||
return { ...edge, style, markerEnd: undefined, labelShowBg: false };
|
||||
}
|
||||
|
||||
export { BUILTIN_EDGE_DEFAULTS };
|
||||
459
web/src/features/topology/lib/exportTopology.ts
Normal file
459
web/src/features/topology/lib/exportTopology.ts
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
import { getStraightPath } from "@xyflow/react";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { TopologyViewGraph, TopologyViewItem, TopologyViewNodeItem } from "../../../types";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import { isAggregateEdgeId, type LinkEdgeData } from "./linkDisplay";
|
||||
import {
|
||||
TOPO_HANDLE_X,
|
||||
TOPO_HANDLE_Y,
|
||||
TOPO_ICON,
|
||||
TOPO_NODE_W,
|
||||
TOPO_REGION_HANDLE_X,
|
||||
TOPO_REGION_HANDLE_Y,
|
||||
TOPO_REGION_ICON_H,
|
||||
} from "./topoGeometry";
|
||||
import type { VendorColors } from "./displayPrefs";
|
||||
import { vendorColorForNode } from "./vendorTone";
|
||||
|
||||
const NE_ICON_URL = "/topo/ne-router.png";
|
||||
const REGION_ICON_URL = "/topo/region-building.png";
|
||||
|
||||
export type TopologyVectorSvgTheme = {
|
||||
labelColor: string;
|
||||
edgeLabelColor: string;
|
||||
vendorColors: VendorColors;
|
||||
hideIp: boolean;
|
||||
hideVendor: boolean;
|
||||
};
|
||||
|
||||
type IconAssets = {
|
||||
neTinted: Map<string, string>;
|
||||
regionPlain: string;
|
||||
};
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
const trimmed = String(name || "topology")
|
||||
.trim()
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
|
||||
.replace(/\s+/g, "_")
|
||||
.slice(0, 80);
|
||||
return trimmed || "topology";
|
||||
}
|
||||
|
||||
function downloadBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
}
|
||||
|
||||
function downloadText(filename: string, text: string, mime: string): void {
|
||||
downloadBlob(filename, new Blob([text], { type: mime }));
|
||||
}
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error(`failed to load icon: ${url}`));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/** Match canvas: `.topo-node__icon-art` + masked `.topo-node__icon-tint` (mix-blend-mode: color). */
|
||||
async function tintedIconDataUrl(
|
||||
img: HTMLImageElement,
|
||||
color: string,
|
||||
w: number,
|
||||
h: number,
|
||||
): Promise<string> {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("canvas unsupported");
|
||||
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
|
||||
const tint = document.createElement("canvas");
|
||||
tint.width = w;
|
||||
tint.height = h;
|
||||
const tctx = tint.getContext("2d");
|
||||
if (!tctx) throw new Error("canvas unsupported");
|
||||
tctx.fillStyle = color;
|
||||
tctx.fillRect(0, 0, w, h);
|
||||
tctx.globalCompositeOperation = "destination-in";
|
||||
tctx.drawImage(img, 0, 0, w, h);
|
||||
|
||||
ctx.globalCompositeOperation = "color";
|
||||
ctx.drawImage(tint, 0, 0);
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
async function plainIconDataUrl(img: HTMLImageElement, w: number, h: number): Promise<string> {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("canvas unsupported");
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
async function prepareIconAssets(
|
||||
nodes: Node<NeNodeData>[],
|
||||
theme: TopologyVectorSvgTheme,
|
||||
): Promise<IconAssets> {
|
||||
const [neImg, regionImg] = await Promise.all([loadImage(NE_ICON_URL), loadImage(REGION_ICON_URL)]);
|
||||
const colors = new Set<string>();
|
||||
for (const n of nodes) {
|
||||
if (!isRegionNode(n)) colors.add(vendorColor(theme, n.data));
|
||||
}
|
||||
const neTinted = new Map<string, string>();
|
||||
await Promise.all(
|
||||
[...colors].map(async (color) => {
|
||||
neTinted.set(color, await tintedIconDataUrl(neImg, color, TOPO_ICON, TOPO_ICON));
|
||||
}),
|
||||
);
|
||||
const regionPlain = await plainIconDataUrl(regionImg, TOPO_ICON, TOPO_REGION_ICON_H);
|
||||
return { neTinted, regionPlain };
|
||||
}
|
||||
|
||||
function isRegionNode(node: Node<NeNodeData>): boolean {
|
||||
return node.data.kind === "region" || String(node.id || "").startsWith("region:");
|
||||
}
|
||||
|
||||
function nodeApiPosition(node: Node<NeNodeData>): { x: number; y: number } {
|
||||
const isRegion = isRegionNode(node);
|
||||
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
|
||||
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
|
||||
return { x: node.position.x + hx, y: node.position.y + hy };
|
||||
}
|
||||
|
||||
function nodeGlyphBox(node: Node<NeNodeData>): { x: number; y: number; w: number; h: number } {
|
||||
const isRegion = isRegionNode(node);
|
||||
const w = TOPO_ICON;
|
||||
const h = isRegion ? TOPO_REGION_ICON_H : TOPO_ICON;
|
||||
return {
|
||||
x: node.position.x + (TOPO_NODE_W - w) / 2,
|
||||
y: node.position.y,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeHandleCenter(node: Node<NeNodeData>): { x: number; y: number } {
|
||||
const box = nodeGlyphBox(node);
|
||||
return { x: box.x + box.w / 2, y: box.y + box.h / 2 };
|
||||
}
|
||||
|
||||
function vendorColor(theme: TopologyVectorSvgTheme, data: NeNodeData): string {
|
||||
return vendorColorForNode(data, theme.vendorColors);
|
||||
}
|
||||
|
||||
function xmlEscape(value: string): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function xmlAttr(name: string, value: string | number | boolean | null | undefined): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
return ` ${name}="${xmlEscape(String(value))}"`;
|
||||
}
|
||||
|
||||
type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
|
||||
|
||||
function growBounds(bounds: Bounds, x: number, y: number, pad = 0): void {
|
||||
bounds.minX = Math.min(bounds.minX, x - pad);
|
||||
bounds.minY = Math.min(bounds.minY, y - pad);
|
||||
bounds.maxX = Math.max(bounds.maxX, x + pad);
|
||||
bounds.maxY = Math.max(bounds.maxY, y + pad);
|
||||
}
|
||||
|
||||
function nodeCaptionLines(node: Node<NeNodeData>, theme: TopologyVectorSvgTheme): string[] {
|
||||
const data = node.data;
|
||||
const isRegion = isRegionNode(node);
|
||||
const name = data.label || (!theme.hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
|
||||
const lines = [name];
|
||||
if (!isRegion) {
|
||||
const meta = [
|
||||
theme.hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
|
||||
theme.hideVendor || !data.vendor ? "" : data.vendor,
|
||||
].filter(Boolean);
|
||||
if (meta.length) lines.push(meta.join(" / "));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function computeGraphBounds(
|
||||
nodes: Node<NeNodeData>[],
|
||||
displayEdges: Edge[],
|
||||
centers: Map<string, { x: number; y: number }>,
|
||||
theme: TopologyVectorSvgTheme,
|
||||
): Bounds {
|
||||
const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
||||
for (const node of nodes) {
|
||||
const box = nodeGlyphBox(node);
|
||||
growBounds(bounds, box.x, box.y);
|
||||
growBounds(bounds, box.x + box.w, box.y + box.h);
|
||||
const lines = nodeCaptionLines(node, theme);
|
||||
const captionY = box.y + box.h + 1;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const approxWidth = Math.max(24, line.length * (i === 0 ? 5 : 4.2));
|
||||
const lineY = captionY + i * (i === 0 ? 10 : 8);
|
||||
growBounds(bounds, box.x + box.w / 2 - approxWidth / 2, lineY);
|
||||
growBounds(bounds, box.x + box.w / 2 + approxWidth / 2, lineY + (i === 0 ? 10 : 8));
|
||||
}
|
||||
}
|
||||
for (const edge of displayEdges) {
|
||||
const a = centers.get(edge.source);
|
||||
const b = centers.get(edge.target);
|
||||
if (!a || !b) continue;
|
||||
growBounds(bounds, a.x, a.y);
|
||||
growBounds(bounds, b.x, b.y);
|
||||
const label = String(edge.label || "").trim();
|
||||
if (label) {
|
||||
const mx = (a.x + b.x) / 2;
|
||||
const my = (a.y + b.y) / 2;
|
||||
growBounds(bounds, mx, my, Math.max(20, label.length * 3));
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(bounds.minX)) {
|
||||
return { minX: 0, minY: 0, maxX: 800, maxY: 600 };
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function edgeVisual(edge: Edge): { stroke: string; strokeWidth: number; strokeDasharray?: string } {
|
||||
const style = (edge.style || {}) as {
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
strokeDasharray?: string;
|
||||
};
|
||||
return {
|
||||
stroke: String(style.stroke || "#64748b"),
|
||||
strokeWidth: Number(style.strokeWidth || 2),
|
||||
strokeDasharray: style.strokeDasharray ? String(style.strokeDasharray) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function renderNodeIcon(
|
||||
node: Node<NeNodeData>,
|
||||
theme: TopologyVectorSvgTheme,
|
||||
icons: IconAssets,
|
||||
): string {
|
||||
const box = nodeGlyphBox(node);
|
||||
if (isRegionNode(node)) {
|
||||
return `<image href="${icons.regionPlain}" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" preserveAspectRatio="xMidYMid meet"/>`;
|
||||
}
|
||||
const color = vendorColor(theme, node.data);
|
||||
const href = icons.neTinted.get(color);
|
||||
if (!href) return "";
|
||||
return `<image href="${href}" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" preserveAspectRatio="xMidYMid meet"/>`;
|
||||
}
|
||||
|
||||
function renderNode(node: Node<NeNodeData>, theme: TopologyVectorSvgTheme, icons: IconAssets): string {
|
||||
const box = nodeGlyphBox(node);
|
||||
const centerX = box.x + box.w / 2;
|
||||
const icon = renderNodeIcon(node, theme, icons);
|
||||
const lines = nodeCaptionLines(node, theme);
|
||||
const captionParts = lines.map((line, idx) => {
|
||||
const y = box.y + box.h + 1 + (idx === 0 ? 8 : 8 + idx * 8);
|
||||
const fontSize = idx === 0 ? 8 : 7;
|
||||
const opacity = idx === 0 ? 1 : 0.72;
|
||||
return `<text x="${centerX}" y="${y}" text-anchor="middle" font-family="Segoe UI, system-ui, sans-serif" font-size="${fontSize}" font-weight="${idx === 0 ? 600 : 400}" fill="${xmlEscape(theme.labelColor)}" opacity="${opacity}">${xmlEscape(line)}</text>`;
|
||||
});
|
||||
return [
|
||||
`<g id="node-${xmlEscape(node.id)}" class="topo-export-node">`,
|
||||
icon,
|
||||
...captionParts,
|
||||
`</g>`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderEdge(
|
||||
edge: Edge,
|
||||
centers: Map<string, { x: number; y: number }>,
|
||||
theme: TopologyVectorSvgTheme,
|
||||
): string {
|
||||
const a = centers.get(edge.source);
|
||||
const b = centers.get(edge.target);
|
||||
if (!a || !b) return "";
|
||||
const data = (edge.data || {}) as LinkEdgeData;
|
||||
const index = Number(data.parallelIndex || 0);
|
||||
const count = Math.max(1, Number(data.parallelCount || 1));
|
||||
const offset = (index - (count - 1) / 2) * 10;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ox = (-dy / len) * offset;
|
||||
const oy = (dx / len) * offset;
|
||||
const sx = a.x + ox;
|
||||
const sy = a.y + oy;
|
||||
const tx = b.x + ox;
|
||||
const ty = b.y + oy;
|
||||
const visual = edgeVisual(edge);
|
||||
const dash = visual.strokeDasharray
|
||||
? ` stroke-dasharray="${xmlEscape(visual.strokeDasharray)}"`
|
||||
: "";
|
||||
const [path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: tx, targetY: ty });
|
||||
const label = String(edge.label || "").trim();
|
||||
const labelMarkup = label
|
||||
? `<text x="${labelX}" y="${labelY}" text-anchor="middle" dominant-baseline="middle" font-family="ui-monospace, SFMono-Regular, Consolas, monospace" font-size="7" fill="${xmlEscape(theme.edgeLabelColor)}">${xmlEscape(label)}</text>`
|
||||
: "";
|
||||
return [
|
||||
`<g id="edge-${xmlEscape(edge.id)}" class="topo-export-edge">`,
|
||||
`<path d="${xmlEscape(path)}" fill="none" stroke="${xmlEscape(visual.stroke)}" stroke-width="${visual.strokeWidth}" stroke-linecap="round"${dash}/>`,
|
||||
labelMarkup,
|
||||
`</g>`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
/** Build a vector SVG using the same PNG icons + vendor tint as the canvas. */
|
||||
export async function buildTopologyVectorSvg(
|
||||
nodes: Node<NeNodeData>[],
|
||||
displayEdges: Edge[],
|
||||
theme: TopologyVectorSvgTheme,
|
||||
title?: string,
|
||||
): Promise<string> {
|
||||
const icons = await prepareIconAssets(nodes, theme);
|
||||
const centers = new Map(nodes.map((n) => [n.id, nodeHandleCenter(n)] as const));
|
||||
const bounds = computeGraphBounds(nodes, displayEdges, centers, theme);
|
||||
const pad = 48;
|
||||
const minX = bounds.minX - pad;
|
||||
const minY = bounds.minY - pad;
|
||||
const width = bounds.maxX - bounds.minX + pad * 2;
|
||||
const height = bounds.maxY - bounds.minY + pad * 2;
|
||||
const edgeMarkup = displayEdges.map((e) => renderEdge(e, centers, theme)).join("");
|
||||
const nodeMarkup = nodes.map((n) => renderNode(n, theme, icons)).join("");
|
||||
const titleMarkup = title ? `<title>${xmlEscape(title)}</title>` : "";
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${minX} ${minY} ${width} ${height}" width="${Math.round(width)}" height="${Math.round(height)}">`,
|
||||
titleMarkup,
|
||||
`<desc>netx topology vector export</desc>`,
|
||||
`<g id="edges">${edgeMarkup}</g>`,
|
||||
`<g id="nodes">${nodeMarkup}</g>`,
|
||||
`</svg>`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildExportGraph(
|
||||
view: TopologyViewItem,
|
||||
nodes: Node<NeNodeData>[],
|
||||
edges: Edge[],
|
||||
serverGraph?: TopologyViewGraph | null,
|
||||
): TopologyViewGraph {
|
||||
const serverNodeMap = new Map(
|
||||
(serverGraph?.nodes || []).map((n) => [n.fabric_node_id, n] as const),
|
||||
);
|
||||
const serverEdgeMap = new Map((serverGraph?.edges || []).map((e) => [e.id, e] as const));
|
||||
|
||||
const exportNodes: TopologyViewNodeItem[] = nodes.map((n) => {
|
||||
const pos = nodeApiPosition(n);
|
||||
const server = serverNodeMap.get(n.id);
|
||||
return {
|
||||
fabric_node_id: n.id,
|
||||
managed_ne_id: n.data.managed_ne_id || server?.managed_ne_id || "",
|
||||
ume_ne_id: n.data.ume_ne_id || server?.ume_ne_id || "",
|
||||
label: n.data.label || server?.label || "",
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
locked: server?.locked || false,
|
||||
name: server?.name || n.data.label || "",
|
||||
ip: n.data.ne_ip || server?.ip || "",
|
||||
vendor: n.data.vendor || server?.vendor || "",
|
||||
device_type: server?.device_type || (isRegionNode(n) ? "region" : ""),
|
||||
connect_status: n.data.connect_status || server?.connect_status || "",
|
||||
managed_source: n.data.managed_source || server?.managed_source,
|
||||
kind: n.data.kind || server?.kind,
|
||||
folder_id: n.data.folder_id || server?.folder_id,
|
||||
view_id: n.data.view_id || server?.view_id,
|
||||
node_count: n.data.node_count ?? server?.node_count,
|
||||
};
|
||||
});
|
||||
|
||||
const physicalEdges = edges.filter((e) => !isAggregateEdgeId(String(e.id)));
|
||||
const exportEdges = physicalEdges.map((e) => {
|
||||
const data = (e.data || {}) as LinkEdgeData;
|
||||
const server = serverEdgeMap.get(e.id);
|
||||
return {
|
||||
id: e.id,
|
||||
a_node_id: e.source,
|
||||
b_node_id: e.target,
|
||||
a_port: data.source_port || server?.a_port || "",
|
||||
b_port: data.target_port || server?.b_port || "",
|
||||
source: data.source || server?.source || "manual",
|
||||
status: server?.status || "active",
|
||||
layer: server?.layer || "physical",
|
||||
stroke_color: data.stroke_color || server?.stroke_color,
|
||||
stroke_width: data.stroke_width || server?.stroke_width,
|
||||
line_style: data.line_style || server?.line_style,
|
||||
discovered_at: data.discovered_at ?? server?.discovered_at ?? null,
|
||||
display_label: data.display_label || server?.display_label,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
view,
|
||||
nodes: exportNodes,
|
||||
edges: exportEdges,
|
||||
truncated: serverGraph?.truncated,
|
||||
truncate_reason: serverGraph?.truncate_reason,
|
||||
outside_peers: serverGraph?.outside_peers,
|
||||
world_transform: serverGraph?.world_transform,
|
||||
scatter: serverGraph?.scatter,
|
||||
};
|
||||
}
|
||||
|
||||
export function topologyGraphToXml(graph: TopologyViewGraph): string {
|
||||
const view = graph.view;
|
||||
const lines: string[] = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<topology xmlns="urn:netx:topology:export" version="1"${xmlAttr("exported-at", new Date().toISOString())}>`,
|
||||
" <view",
|
||||
`${xmlAttr("id", view.id)}${xmlAttr("name", view.name)}${xmlAttr("kind", view.kind)}${xmlAttr("folder_id", view.folder_id)}${xmlAttr("remark", view.remark || "")} />`,
|
||||
` <nodes count="${graph.nodes.length}">`,
|
||||
];
|
||||
|
||||
for (const n of graph.nodes) {
|
||||
lines.push(
|
||||
` <node${xmlAttr("id", n.fabric_node_id)}${xmlAttr("x", n.x)}${xmlAttr("y", n.y)}${xmlAttr("label", n.label)}${xmlAttr("name", n.name)}${xmlAttr("ip", n.ip)}${xmlAttr("vendor", n.vendor)}${xmlAttr("kind", n.kind)}${xmlAttr("managed_ne_id", n.managed_ne_id)}${xmlAttr("ume_ne_id", n.ume_ne_id)}${xmlAttr("folder_id", n.folder_id)}${xmlAttr("view_id", n.view_id)} />`,
|
||||
);
|
||||
}
|
||||
lines.push(" </nodes>");
|
||||
lines.push(` <edges count="${graph.edges.length}">`);
|
||||
|
||||
for (const e of graph.edges) {
|
||||
lines.push(
|
||||
` <edge${xmlAttr("id", e.id)}${xmlAttr("a_node_id", e.a_node_id)}${xmlAttr("b_node_id", e.b_node_id)}${xmlAttr("a_port", e.a_port)}${xmlAttr("b_port", e.b_port)}${xmlAttr("source", e.source)}${xmlAttr("status", e.status)}${xmlAttr("layer", e.layer)}${xmlAttr("display_label", e.display_label || "")} />`,
|
||||
);
|
||||
}
|
||||
lines.push(" </edges>");
|
||||
lines.push("</topology>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function exportTopologySvg(
|
||||
nodes: Node<NeNodeData>[],
|
||||
displayEdges: Edge[],
|
||||
filenameBase: string,
|
||||
theme: TopologyVectorSvgTheme,
|
||||
title?: string,
|
||||
): Promise<void> {
|
||||
const svg = await buildTopologyVectorSvg(nodes, displayEdges, theme, title);
|
||||
downloadText(`${sanitizeFilename(filenameBase)}.svg`, svg, "image/svg+xml;charset=utf-8");
|
||||
}
|
||||
|
||||
export function exportTopologyXml(graph: TopologyViewGraph, filenameBase: string): void {
|
||||
const xml = topologyGraphToXml(graph);
|
||||
downloadText(`${sanitizeFilename(filenameBase)}.xml`, xml, "application/xml;charset=utf-8");
|
||||
}
|
||||
189
web/src/features/topology/lib/graphFlow.ts
Normal file
189
web/src/features/topology/lib/graphFlow.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type { Edge, Node, Position } from "@xyflow/react";
|
||||
import type { TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem } from "../../../types";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import { formatPortPairLabel } from "./linkDisplay";
|
||||
import type { EdgeDefaults, EdgeStyleData } from "./edgeStyle";
|
||||
import { withEdgeVisual } from "./edgeStyle";
|
||||
import {
|
||||
TOPO_NODE_H,
|
||||
TOPO_NODE_W,
|
||||
TOPO_REGION_HANDLE_X,
|
||||
TOPO_REGION_HANDLE_Y,
|
||||
TOPO_REGION_ICON_H,
|
||||
TOPO_HANDLE_X,
|
||||
TOPO_HANDLE_Y,
|
||||
neApiPosition,
|
||||
neFlowPosition,
|
||||
regionApiPosition,
|
||||
regionFlowPosition,
|
||||
} from "./topoGeometry";
|
||||
|
||||
export function graphToFlow(
|
||||
nodes: TopologyViewNodeItem[],
|
||||
edges: TopologyViewEdgeItem[],
|
||||
defaults: EdgeDefaults,
|
||||
) {
|
||||
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => {
|
||||
const isRegion =
|
||||
n.kind === "region" || n.device_type === "region" || String(n.fabric_node_id || "").startsWith("region:");
|
||||
const apiX = Number(n.x) || 0;
|
||||
const apiY = Number(n.y) || 0;
|
||||
const position = isRegion ? regionFlowPosition(apiX, apiY) : neFlowPosition(apiX, apiY);
|
||||
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
|
||||
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
|
||||
return {
|
||||
id: n.fabric_node_id,
|
||||
type: "neNode",
|
||||
position,
|
||||
width: TOPO_NODE_W,
|
||||
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
|
||||
handles: [
|
||||
{ type: "target", position: "left" as Position, x: hx, y: hy },
|
||||
{ type: "source", position: "right" as Position, x: hx, y: hy },
|
||||
],
|
||||
data: {
|
||||
label: n.label || n.name || n.ip || n.fabric_node_id,
|
||||
managed_ne_id: n.managed_ne_id || "",
|
||||
ume_ne_id: n.ume_ne_id || "",
|
||||
ne_ip: n.ip || "",
|
||||
vendor: n.vendor || "",
|
||||
connect_status: n.connect_status || "",
|
||||
managed_source: n.managed_source || "",
|
||||
level: n.level ?? null,
|
||||
fabric_role: n.role || "",
|
||||
kind: (n.kind as NeNodeData["kind"]) || (isRegion ? "region" : "ne"),
|
||||
folder_id: n.folder_id || "",
|
||||
view_id: n.view_id || "",
|
||||
node_count: n.node_count || 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
const rfEdges: Edge[] = edges.map((e) => {
|
||||
const src = e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual";
|
||||
const label = formatPortPairLabel(e.a_port || "", e.b_port || "", e.display_label || "");
|
||||
const data: EdgeStyleData = {
|
||||
source: src,
|
||||
source_port: e.a_port || "",
|
||||
target_port: e.b_port || "",
|
||||
stroke_color: e.stroke_color || "",
|
||||
stroke_width: Number(e.stroke_width || 0),
|
||||
line_style: e.line_style || "",
|
||||
discovered_at: e.discovered_at ?? null,
|
||||
display_label: e.display_label || "",
|
||||
};
|
||||
return withEdgeVisual(
|
||||
{
|
||||
id: e.id,
|
||||
source: e.a_node_id,
|
||||
target: e.b_node_id,
|
||||
type: "straight",
|
||||
label: label || undefined,
|
||||
animated: false,
|
||||
data,
|
||||
},
|
||||
defaults,
|
||||
);
|
||||
});
|
||||
return { rfNodes, rfEdges };
|
||||
}
|
||||
|
||||
export function flowToPositions(nodes: Node<NeNodeData>[]) {
|
||||
return nodes.map((n) => {
|
||||
const isRegion = n.data.kind === "region" || String(n.id || "").startsWith("region:");
|
||||
const pos = isRegion
|
||||
? regionApiPosition(n.position.x, n.position.y)
|
||||
: neApiPosition(n.position.x, n.position.y);
|
||||
return {
|
||||
fabric_node_id: n.id,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
label: n.data.label || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function graphFingerprint(graph: TopologyViewGraph): string {
|
||||
const nodes = [...graph.nodes]
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}:${n.connect_status || ""}:${n.level ?? ""}:${n.role || ""}`,
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
const edges = [...graph.edges]
|
||||
.map((e) => `${e.id}:${e.a_node_id}:${e.b_node_id}:${e.a_port || ""}:${e.b_port || ""}:${e.status || ""}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}#${graph.scatter?.length || 0}#${graph.world_transform?.total || 0}`;
|
||||
}
|
||||
|
||||
/** Overlay / inventory fields safe to refresh while the canvas has unsaved geometry edits. */
|
||||
export function overlayFingerprint(graph: TopologyViewGraph): string {
|
||||
return [...graph.nodes]
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.fabric_node_id}:${n.connect_status || ""}:${n.level ?? ""}:${n.role || ""}:${n.ume_ne_id || ""}:${n.managed_ne_id || ""}:${n.ip || ""}:${n.vendor || ""}:${n.managed_source || ""}`,
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch live overlay fields onto existing RF nodes without touching positions/selection.
|
||||
* Used when dirty lock blocks a full graph apply.
|
||||
*/
|
||||
export function mergeOverlayIntoNodes(
|
||||
current: Node<NeNodeData>[],
|
||||
graph: TopologyViewGraph,
|
||||
): { nodes: Node<NeNodeData>[]; changed: boolean } {
|
||||
const byId = new Map(graph.nodes.map((n) => [n.fabric_node_id, n]));
|
||||
let changed = false;
|
||||
const nodes = current.map((node) => {
|
||||
const g = byId.get(node.id);
|
||||
if (!g || node.data.kind === "region") return node;
|
||||
const next = {
|
||||
connect_status: g.connect_status || "",
|
||||
level: g.level ?? null,
|
||||
fabric_role: g.role || "",
|
||||
ume_ne_id: g.ume_ne_id || "",
|
||||
managed_ne_id: g.managed_ne_id || "",
|
||||
ne_ip: g.ip || "",
|
||||
vendor: g.vendor || "",
|
||||
managed_source: g.managed_source || "",
|
||||
};
|
||||
if (
|
||||
node.data.connect_status === next.connect_status &&
|
||||
(node.data.level ?? null) === next.level &&
|
||||
(node.data.fabric_role || "") === next.fabric_role &&
|
||||
(node.data.ume_ne_id || "") === next.ume_ne_id &&
|
||||
(node.data.managed_ne_id || "") === next.managed_ne_id &&
|
||||
(node.data.ne_ip || "") === next.ne_ip &&
|
||||
(node.data.vendor || "") === next.vendor &&
|
||||
(node.data.managed_source || "") === next.managed_source
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
changed = true;
|
||||
return { ...node, data: { ...node.data, ...next } };
|
||||
});
|
||||
return { nodes, changed };
|
||||
}
|
||||
|
||||
export function applyViewGraph(
|
||||
graph: TopologyViewGraph,
|
||||
defaults: EdgeDefaults,
|
||||
setNodes: (ns: Node<NeNodeData>[]) => void,
|
||||
setEdges: (es: Edge[]) => void,
|
||||
localPositions?: Map<string, { x: number; y: number }>,
|
||||
) {
|
||||
const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults);
|
||||
const merged = localPositions?.size
|
||||
? rfNodes.map((n) => {
|
||||
const p = localPositions.get(n.id);
|
||||
return p ? { ...n, position: { ...p } } : n;
|
||||
})
|
||||
: rfNodes;
|
||||
setNodes(merged);
|
||||
setEdges(rfEdges);
|
||||
return { rfNodes: merged, rfEdges };
|
||||
}
|
||||
23
web/src/features/topology/lib/idUtils.ts
Normal file
23
web/src/features/topology/lib/idUtils.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { TopologyDiscoverNeResult } from "../../../types";
|
||||
|
||||
export function newId(): string {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function newLocalEdgeId(): string {
|
||||
return `local:${newId()}`;
|
||||
}
|
||||
|
||||
export function isLocalPendingEdgeId(id: string): boolean {
|
||||
return String(id || "").startsWith("local:");
|
||||
}
|
||||
|
||||
export function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" {
|
||||
if (!r.ok) return "fail";
|
||||
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
|
||||
if (r.parser_stub || unmatchedCount > 0) return "warn";
|
||||
return "ok";
|
||||
}
|
||||
248
web/src/features/topology/lib/layoutGraph.ts
Normal file
248
web/src/features/topology/lib/layoutGraph.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
export type LayoutKind = "hierarchical-tb" | "hierarchical-lr" | "force" | "grid" | "radial";
|
||||
|
||||
const NODE_W = 140;
|
||||
const NODE_H = 72;
|
||||
|
||||
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>>(
|
||||
nodes: Node<T>[],
|
||||
positions: Map<string, XY>,
|
||||
): Node<T>[] {
|
||||
return nodes.map((n) => {
|
||||
const p = positions.get(n.id);
|
||||
if (!p) return n;
|
||||
return { ...n, position: { x: p.x, y: p.y } };
|
||||
});
|
||||
}
|
||||
|
||||
async function layoutDagre<T extends Record<string, unknown>>(
|
||||
nodes: Node<T>[],
|
||||
edges: Edge[],
|
||||
rankdir: "TB" | "LR",
|
||||
): Promise<Node<T>[]> {
|
||||
const dagre = await loadDagre();
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir, nodesep: 48, ranksep: 72, marginx: 24, marginy: 24 });
|
||||
for (const n of nodes) {
|
||||
g.setNode(n.id, { width: NODE_W, height: NODE_H });
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (g.hasNode(e.source) && g.hasNode(e.target)) {
|
||||
g.setEdge(e.source, e.target);
|
||||
}
|
||||
}
|
||||
dagre.layout(g);
|
||||
const positions = new Map<string, XY>();
|
||||
for (const n of nodes) {
|
||||
const dn = g.node(n.id);
|
||||
if (!dn) continue;
|
||||
positions.set(n.id, { x: dn.x - NODE_W / 2, y: dn.y - NODE_H / 2 });
|
||||
}
|
||||
return applyPositions(nodes, positions);
|
||||
}
|
||||
|
||||
/** Lightweight spring layout (no d3 dependency). */
|
||||
function layoutForce<T extends Record<string, unknown>>(
|
||||
nodes: Node<T>[],
|
||||
edges: Edge[],
|
||||
iterations = 120,
|
||||
): Node<T>[] {
|
||||
if (nodes.length === 0) return nodes;
|
||||
const pos = new Map<string, XY>();
|
||||
const vel = new Map<string, XY>();
|
||||
const cx = 400;
|
||||
const cy = 300;
|
||||
nodes.forEach((n, i) => {
|
||||
const angle = (2 * Math.PI * i) / nodes.length;
|
||||
const r = 80 + nodes.length * 4;
|
||||
pos.set(n.id, {
|
||||
x: n.position?.x || cx + Math.cos(angle) * r,
|
||||
y: n.position?.y || cy + Math.sin(angle) * r,
|
||||
});
|
||||
vel.set(n.id, { x: 0, y: 0 });
|
||||
});
|
||||
const ids = nodes.map((n) => n.id);
|
||||
const links = edges
|
||||
.filter((e) => pos.has(e.source) && pos.has(e.target))
|
||||
.map((e) => ({ s: e.source, t: e.target }));
|
||||
|
||||
const repulsion = 2800;
|
||||
const attraction = 0.04;
|
||||
const ideal = 160;
|
||||
const damping = 0.85;
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
for (const id of ids) {
|
||||
const p = pos.get(id)!;
|
||||
let fx = 0;
|
||||
let fy = 0;
|
||||
for (const other of ids) {
|
||||
if (other === id) continue;
|
||||
const q = pos.get(other)!;
|
||||
let dx = p.x - q.x;
|
||||
let dy = p.y - q.y;
|
||||
let dist2 = dx * dx + dy * dy;
|
||||
if (dist2 < 1) {
|
||||
dx = Math.random() - 0.5;
|
||||
dy = Math.random() - 0.5;
|
||||
dist2 = dx * dx + dy * dy;
|
||||
}
|
||||
const dist = Math.sqrt(dist2);
|
||||
const force = repulsion / dist2;
|
||||
fx += (dx / dist) * force;
|
||||
fy += (dy / dist) * force;
|
||||
}
|
||||
for (const link of links) {
|
||||
if (link.s !== id && link.t !== id) continue;
|
||||
const other = link.s === id ? link.t : link.s;
|
||||
const q = pos.get(other)!;
|
||||
const dx = q.x - p.x;
|
||||
const dy = q.y - p.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const force = (dist - ideal) * attraction;
|
||||
fx += (dx / dist) * force;
|
||||
fy += (dy / dist) * force;
|
||||
}
|
||||
const v = vel.get(id)!;
|
||||
v.x = (v.x + fx) * damping;
|
||||
v.y = (v.y + fy) * damping;
|
||||
p.x += v.x;
|
||||
p.y += v.y;
|
||||
}
|
||||
}
|
||||
return applyPositions(nodes, pos);
|
||||
}
|
||||
|
||||
function layoutGrid<T extends Record<string, unknown>>(nodes: Node<T>[]): Node<T>[] {
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
|
||||
const gapX = NODE_W + 40;
|
||||
const gapY = NODE_H + 48;
|
||||
return nodes.map((n, i) => ({
|
||||
...n,
|
||||
position: {
|
||||
x: (i % cols) * gapX + 40,
|
||||
y: Math.floor(i / cols) * gapY + 40,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function layoutRadial<T extends Record<string, unknown>>(nodes: Node<T>[]): Node<T>[] {
|
||||
if (nodes.length === 0) return nodes;
|
||||
if (nodes.length === 1) {
|
||||
return [{ ...nodes[0], position: { x: 320, y: 240 } }];
|
||||
}
|
||||
const cx = 400;
|
||||
const cy = 300;
|
||||
const r = 60 + nodes.length * 18;
|
||||
return nodes.map((n, i) => {
|
||||
const angle = (2 * Math.PI * i) / nodes.length - Math.PI / 2;
|
||||
return {
|
||||
...n,
|
||||
position: {
|
||||
x: cx + Math.cos(angle) * r - NODE_W / 2,
|
||||
y: cy + Math.sin(angle) * r - NODE_H / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 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>[],
|
||||
edges: Edge[],
|
||||
kind: LayoutKind,
|
||||
opts?: { onlyIds?: Set<string> },
|
||||
): Promise<Node<T>[]> {
|
||||
const only = opts?.onlyIds;
|
||||
const targets = only && only.size > 0 ? nodes.filter((n) => only.has(n.id)) : nodes;
|
||||
const targetIds = new Set(targets.map((n) => n.id));
|
||||
const subEdges = edges.filter((e) => targetIds.has(e.source) && targetIds.has(e.target));
|
||||
|
||||
let laid: Node<T>[];
|
||||
switch (kind) {
|
||||
case "hierarchical-lr":
|
||||
laid = await layoutDagre(targets, subEdges, "LR");
|
||||
break;
|
||||
case "force":
|
||||
laid = layoutForce(targets, subEdges);
|
||||
break;
|
||||
case "grid":
|
||||
laid = layoutGrid(targets);
|
||||
break;
|
||||
case "radial":
|
||||
laid = layoutRadial(targets);
|
||||
break;
|
||||
case "hierarchical-tb":
|
||||
default:
|
||||
laid = await layoutDagre(targets, subEdges, "TB");
|
||||
break;
|
||||
}
|
||||
|
||||
if (!only || only.size === 0) return laid;
|
||||
const byId = new Map(laid.map((n) => [n.id, n]));
|
||||
return nodes.map((n) => byId.get(n.id) || n);
|
||||
}
|
||||
|
||||
export function alignNodes<T extends Record<string, unknown>>(
|
||||
nodes: Node<T>[],
|
||||
ids: string[],
|
||||
kind: "left" | "right" | "top" | "bottom" | "h-center" | "v-center" | "h-distribute" | "v-distribute",
|
||||
): Node<T>[] {
|
||||
const selected = nodes.filter((n) => ids.includes(n.id));
|
||||
if (selected.length < 2) return nodes;
|
||||
const xs = selected.map((n) => n.position.x);
|
||||
const ys = selected.map((n) => n.position.y);
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const maxY = Math.max(...ys);
|
||||
const midX = (minX + maxX) / 2;
|
||||
const midY = (minY + maxY) / 2;
|
||||
|
||||
const pos = new Map<string, XY>();
|
||||
if (kind === "h-distribute") {
|
||||
const sorted = [...selected].sort((a, b) => a.position.x - b.position.x);
|
||||
const span = maxX - minX;
|
||||
sorted.forEach((n, i) => {
|
||||
const x = sorted.length === 1 ? minX : minX + (span * i) / (sorted.length - 1);
|
||||
pos.set(n.id, { x, y: n.position.y });
|
||||
});
|
||||
} else if (kind === "v-distribute") {
|
||||
const sorted = [...selected].sort((a, b) => a.position.y - b.position.y);
|
||||
const span = maxY - minY;
|
||||
sorted.forEach((n, i) => {
|
||||
const y = sorted.length === 1 ? minY : minY + (span * i) / (sorted.length - 1);
|
||||
pos.set(n.id, { x: n.position.x, y });
|
||||
});
|
||||
} else {
|
||||
for (const n of selected) {
|
||||
let x = n.position.x;
|
||||
let y = n.position.y;
|
||||
if (kind === "left") x = minX;
|
||||
if (kind === "right") x = maxX;
|
||||
if (kind === "top") y = minY;
|
||||
if (kind === "bottom") y = maxY;
|
||||
if (kind === "h-center") x = midX;
|
||||
if (kind === "v-center") y = midY;
|
||||
pos.set(n.id, { x, y });
|
||||
}
|
||||
}
|
||||
return applyPositions(nodes, pos);
|
||||
}
|
||||
21
web/src/features/topology/lib/levelTone.ts
Normal file
21
web/src/features/topology/lib/levelTone.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/** Map fabric level/role to a short CSS tone key for canvas chrome. */
|
||||
|
||||
export type LevelToneKey = "external" | "core" | "aggregation" | "access" | "edge" | "";
|
||||
|
||||
export function levelToneKey(level: number | null | undefined, role?: string | null): LevelToneKey {
|
||||
const r = String(role || "").trim().toLowerCase();
|
||||
if (r === "external" || r === "core" || r === "access" || r === "edge") return r;
|
||||
if (r === "aggregation" || r === "aggregate" || r === "agg") return "aggregation";
|
||||
if (level == null || Number.isNaN(Number(level))) return "";
|
||||
const maj = Math.floor(Number(level));
|
||||
if (maj <= 0) return "external";
|
||||
if (maj === 1) return "core";
|
||||
if (maj === 2) return "aggregation";
|
||||
if (maj === 3) return "access";
|
||||
return "edge";
|
||||
}
|
||||
|
||||
export function levelLabelKey(tone: LevelToneKey): string {
|
||||
if (!tone) return "topology.levelUnmatched";
|
||||
return `topology.level.${tone}`;
|
||||
}
|
||||
214
web/src/features/topology/lib/linkDisplay.ts
Normal file
214
web/src/features/topology/lib/linkDisplay.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import type { Edge } from "@xyflow/react";
|
||||
|
||||
export type LinkMember = {
|
||||
id: string;
|
||||
a_port: string;
|
||||
b_port: string;
|
||||
source: string;
|
||||
display_label?: string;
|
||||
};
|
||||
|
||||
export type LinkEdgeData = {
|
||||
source?: string;
|
||||
source_port?: string;
|
||||
target_port?: string;
|
||||
stroke_color?: string;
|
||||
stroke_width?: number;
|
||||
line_style?: string;
|
||||
discovered_at?: string | null;
|
||||
/** UME userLabel when ports are unavailable (no EQ+PTP). */
|
||||
display_label?: string;
|
||||
/** Logical bundle of parallel physical links between the same NE pair. */
|
||||
aggregated?: boolean;
|
||||
member_count?: number;
|
||||
members?: LinkMember[];
|
||||
parallelIndex?: number;
|
||||
parallelCount?: number;
|
||||
};
|
||||
|
||||
export function pairKey(a: string, b: string): string {
|
||||
return a < b ? `${a}::${b}` : `${b}::${a}`;
|
||||
}
|
||||
|
||||
export function isAggregateEdgeId(id: string): boolean {
|
||||
return String(id || "").startsWith("agg:");
|
||||
}
|
||||
|
||||
export function aggregateIdForPair(a: string, b: string): string {
|
||||
return `agg:${pairKey(a, b)}`;
|
||||
}
|
||||
|
||||
function isLabelPlaceholderPort(port: string): boolean {
|
||||
return String(port || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.startsWith("label:");
|
||||
}
|
||||
|
||||
/** Clear A↔B port label (not ambiguous "local/remote"). */
|
||||
export function formatPortPairLabel(aPort: string, bPort: string, displayLabel?: string): string {
|
||||
const shown = String(displayLabel || "").trim();
|
||||
if (shown) return shown;
|
||||
const a = String(aPort || "").trim();
|
||||
const b = String(bPort || "").trim();
|
||||
if (isLabelPlaceholderPort(a) || isLabelPlaceholderPort(b)) return "";
|
||||
if (a && b) return `${a} ↔ ${b}`;
|
||||
return a || b || "";
|
||||
}
|
||||
|
||||
export function formatBundleLabel(
|
||||
count: number,
|
||||
sample?: { a_port?: string; b_port?: string; display_label?: string },
|
||||
opts?: { hidePorts?: boolean },
|
||||
): string {
|
||||
if (count <= 1) {
|
||||
return opts?.hidePorts
|
||||
? ""
|
||||
: formatPortPairLabel(sample?.a_port || "", sample?.b_port || "", sample?.display_label);
|
||||
}
|
||||
if (opts?.hidePorts) return `×${count}`;
|
||||
const sampleLabel = formatPortPairLabel(
|
||||
sample?.a_port || "",
|
||||
sample?.b_port || "",
|
||||
sample?.display_label,
|
||||
);
|
||||
return sampleLabel ? `×${count} · ${sampleLabel}` : `×${count}`;
|
||||
}
|
||||
|
||||
function memberFrom(edge: Edge): LinkMember {
|
||||
const d = (edge.data || {}) as LinkEdgeData;
|
||||
return {
|
||||
id: edge.id,
|
||||
a_port: String(d.source_port || "").trim(),
|
||||
b_port: String(d.target_port || "").trim(),
|
||||
source: String(d.source || "manual"),
|
||||
display_label: String(d.display_label || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function statusRank(source: string): number {
|
||||
if (source === "stale") return 2;
|
||||
if (source === "manual") return 1;
|
||||
return 0; // discovered / lldp preferred
|
||||
}
|
||||
|
||||
/** Pick representative physical edge for an aggregate (prefer live discovered). */
|
||||
function pickPrimary(list: Edge[]): Edge {
|
||||
return [...list].sort((a, b) => {
|
||||
const da = (a.data || {}) as LinkEdgeData;
|
||||
const db = (b.data || {}) as LinkEdgeData;
|
||||
return statusRank(String(da.source || "")) - statusRank(String(db.source || ""));
|
||||
})[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default: one logical edge per NE pair (bundle parallel links).
|
||||
* Expanded: all physical edges, offset when count > 1.
|
||||
*/
|
||||
export function buildLinkDisplayEdges(
|
||||
edges: Edge[],
|
||||
expandPhysical: boolean,
|
||||
hidePorts = false,
|
||||
scaleBundleWidth = false,
|
||||
): Edge[] {
|
||||
const groups = new Map<string, Edge[]>();
|
||||
for (const e of edges) {
|
||||
const k = pairKey(e.source, e.target);
|
||||
const list = groups.get(k);
|
||||
if (list) list.push(e);
|
||||
else groups.set(k, [e]);
|
||||
}
|
||||
|
||||
const out: Edge[] = [];
|
||||
for (const [, list] of groups) {
|
||||
if (expandPhysical) {
|
||||
const count = list.length;
|
||||
list.forEach((e, i) => {
|
||||
const prev = (e.data || {}) as LinkEdgeData;
|
||||
const data: LinkEdgeData = {
|
||||
...prev,
|
||||
aggregated: false,
|
||||
member_count: count,
|
||||
members: list.map(memberFrom),
|
||||
parallelIndex: i,
|
||||
parallelCount: count,
|
||||
};
|
||||
const portLabel = formatPortPairLabel(
|
||||
data.source_port || "",
|
||||
data.target_port || "",
|
||||
data.display_label,
|
||||
);
|
||||
out.push({
|
||||
...e,
|
||||
type: count > 1 ? "topoParallel" : e.type || "straight",
|
||||
// Never fall back to a stale e.label — it may still contain ports.
|
||||
label: hidePorts ? undefined : portLabel || undefined,
|
||||
data,
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (list.length === 1) {
|
||||
const e = list[0];
|
||||
const prev = (e.data || {}) as LinkEdgeData;
|
||||
const data: LinkEdgeData = {
|
||||
...prev,
|
||||
aggregated: false,
|
||||
member_count: 1,
|
||||
members: [memberFrom(e)],
|
||||
};
|
||||
const portLabel = formatPortPairLabel(
|
||||
data.source_port || "",
|
||||
data.target_port || "",
|
||||
data.display_label,
|
||||
);
|
||||
out.push({
|
||||
...e,
|
||||
type: "straight",
|
||||
label: hidePorts ? undefined : portLabel || undefined,
|
||||
data,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = pickPrimary(list);
|
||||
const prev = (primary.data || {}) as LinkEdgeData;
|
||||
const members = list.map(memberFrom);
|
||||
const anyStale = members.some((m) => m.source === "stale");
|
||||
const data: LinkEdgeData = {
|
||||
...prev,
|
||||
source: anyStale ? "stale" : prev.source,
|
||||
aggregated: true,
|
||||
member_count: list.length,
|
||||
members,
|
||||
};
|
||||
// Optional: thicken logical bundles by member count (default keeps single-line width).
|
||||
if (scaleBundleWidth) {
|
||||
data.stroke_width = Math.max(Number(prev.stroke_width || 0), Math.min(8, 2 + list.length));
|
||||
}
|
||||
out.push({
|
||||
...primary,
|
||||
id: aggregateIdForPair(primary.source, primary.target),
|
||||
type: "straight",
|
||||
label: formatBundleLabel(
|
||||
list.length,
|
||||
{ a_port: data.source_port, b_port: data.target_port },
|
||||
{ hidePorts },
|
||||
) || undefined,
|
||||
data,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function physicalIdsForDisplayEdge(edge: Edge | null | undefined, allEdges: Edge[]): string[] {
|
||||
if (!edge) return [];
|
||||
const d = (edge.data || {}) as LinkEdgeData;
|
||||
if (d.members?.length) return d.members.map((m) => m.id);
|
||||
if (isAggregateEdgeId(edge.id)) {
|
||||
const k = edge.id.slice(4);
|
||||
return allEdges.filter((e) => pairKey(e.source, e.target) === k).map((e) => e.id);
|
||||
}
|
||||
return [edge.id];
|
||||
}
|
||||
29
web/src/features/topology/lib/pageTypes.ts
Normal file
29
web/src/features/topology/lib/pageTypes.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
|
||||
export type HistorySnap = {
|
||||
nodes: Node<NeNodeData>[];
|
||||
edges: Edge[];
|
||||
pendingEdgeDeletes: string[];
|
||||
pendingEdgeCreates: string[];
|
||||
};
|
||||
|
||||
export type PaletteSource = "managed" | "ume";
|
||||
|
||||
export type PaletteItem = {
|
||||
key: string;
|
||||
source: PaletteSource;
|
||||
managed_ne_id: string;
|
||||
ume_ne_id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
vendor: string;
|
||||
meta: string;
|
||||
connect_status: string;
|
||||
};
|
||||
|
||||
export type CtxMenu =
|
||||
| { 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 };
|
||||
40
web/src/features/topology/lib/searchUtils.ts
Normal file
40
web/src/features/topology/lib/searchUtils.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { Node } from "@xyflow/react";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
|
||||
export function normalizeSearchText(s: string): string {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/[\s_\-./]+/g, "");
|
||||
}
|
||||
|
||||
/** Case-insensitive substring + loose subsequence (e.g. r1 → router-1). */
|
||||
export function fuzzyIncludes(haystack: string, needle: string): boolean {
|
||||
const h = normalizeSearchText(haystack);
|
||||
const n = normalizeSearchText(needle);
|
||||
if (!n) return true;
|
||||
if (!h) return false;
|
||||
if (h.includes(n)) return true;
|
||||
let i = 0;
|
||||
for (const ch of h) {
|
||||
if (ch === n[i]) i += 1;
|
||||
if (i >= n.length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
|
||||
const tokens = String(query || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (!tokens.length) return false;
|
||||
const bits = [n.data.label, n.data.ne_ip, n.data.vendor, n.data.managed_ne_id, n.data.ume_ne_id];
|
||||
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
35
web/src/features/topology/lib/sidebarLayout.ts
Normal file
35
web/src/features/topology/lib/sidebarLayout.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import {
|
||||
SIDEBAR_WIDTH_DEFAULT,
|
||||
SIDEBAR_WIDTH_KEY,
|
||||
SIDEBAR_WIDTH_MAX,
|
||||
SIDEBAR_WIDTH_MIN,
|
||||
} from "./constants";
|
||||
|
||||
export function loadSidebarWidth(): number {
|
||||
try {
|
||||
const n = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY));
|
||||
if (Number.isFinite(n) && n >= SIDEBAR_WIDTH_MIN) {
|
||||
return Math.min(Math.round(n), SIDEBAR_WIDTH_MAX);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return SIDEBAR_WIDTH_DEFAULT;
|
||||
}
|
||||
|
||||
export function saveSidebarWidth(width: number): void {
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(width)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function clampSidebarWidth(width: number, pageWidth?: number): number {
|
||||
const maxByPage =
|
||||
pageWidth && pageWidth > 0
|
||||
? Math.max(SIDEBAR_WIDTH_MIN, Math.floor(pageWidth * 0.55))
|
||||
: SIDEBAR_WIDTH_MAX;
|
||||
const maxW = Math.min(SIDEBAR_WIDTH_MAX, maxByPage);
|
||||
return Math.max(SIDEBAR_WIDTH_MIN, Math.min(maxW, Math.round(width)));
|
||||
}
|
||||
54
web/src/features/topology/lib/toolMode.ts
Normal file
54
web/src/features/topology/lib/toolMode.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/** Topology editor interaction modes (NMS-style). */
|
||||
|
||||
export type ToolMode = "select" | "pan" | "connect";
|
||||
|
||||
export type ToolModeBehavior = {
|
||||
nodesDraggable: boolean;
|
||||
nodesConnectable: boolean;
|
||||
elementsSelectable: boolean;
|
||||
/** false = no pane pan on drag; true = left-drag pans; array = button indices */
|
||||
panOnDrag: boolean | number[];
|
||||
selectionOnDrag: boolean;
|
||||
panOnScroll: boolean;
|
||||
};
|
||||
|
||||
export function behaviorForMode(mode: ToolMode): ToolModeBehavior {
|
||||
switch (mode) {
|
||||
case "pan":
|
||||
return {
|
||||
nodesDraggable: false,
|
||||
nodesConnectable: false,
|
||||
elementsSelectable: false,
|
||||
panOnDrag: true,
|
||||
selectionOnDrag: false,
|
||||
panOnScroll: true,
|
||||
};
|
||||
case "connect":
|
||||
return {
|
||||
nodesDraggable: false,
|
||||
nodesConnectable: true,
|
||||
elementsSelectable: true,
|
||||
panOnDrag: [1, 2],
|
||||
selectionOnDrag: false,
|
||||
panOnScroll: true,
|
||||
};
|
||||
case "select":
|
||||
default:
|
||||
return {
|
||||
nodesDraggable: true,
|
||||
nodesConnectable: false,
|
||||
elementsSelectable: true,
|
||||
panOnDrag: [1, 2],
|
||||
selectionOnDrag: true,
|
||||
panOnScroll: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function toolModeFromKey(key: string): ToolMode | null {
|
||||
const k = key.toLowerCase();
|
||||
if (k === "v") return "select";
|
||||
if (k === "h") return "pan";
|
||||
if (k === "c") return "connect";
|
||||
return null;
|
||||
}
|
||||
49
web/src/features/topology/lib/topoGeometry.ts
Normal file
49
web/src/features/topology/lib/topoGeometry.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
|
||||
export const TOPO_NODE_W = 88;
|
||||
export const TOPO_NODE_H = 28;
|
||||
export const TOPO_ICON = 28;
|
||||
/** region-building.png is 195×133 — keep aspect so icon center == handle. */
|
||||
export const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195);
|
||||
export const TOPO_HANDLE_X = TOPO_NODE_W / 2;
|
||||
export const TOPO_HANDLE_Y = TOPO_ICON / 2;
|
||||
export const TOPO_REGION_HANDLE_X = TOPO_NODE_W / 2;
|
||||
export const TOPO_REGION_HANDLE_Y = TOPO_REGION_ICON_H / 2;
|
||||
|
||||
/** API x/y is icon center; RF position is top-left of the node box. */
|
||||
export function iconFlowPosition(
|
||||
apiX: number,
|
||||
apiY: number,
|
||||
handleX: number,
|
||||
handleY: number,
|
||||
): { x: number; y: number } {
|
||||
return { x: apiX - handleX, y: apiY - handleY };
|
||||
}
|
||||
|
||||
export function iconApiPosition(
|
||||
flowX: number,
|
||||
flowY: number,
|
||||
handleX: number,
|
||||
handleY: number,
|
||||
): { x: number; y: number } {
|
||||
return { x: flowX + handleX, y: flowY + handleY };
|
||||
}
|
||||
|
||||
export function regionFlowPosition(apiX: number, apiY: number): { x: number; y: number } {
|
||||
return iconFlowPosition(apiX, apiY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y);
|
||||
}
|
||||
|
||||
export function regionApiPosition(flowX: number, flowY: number): { x: number; y: number } {
|
||||
return iconApiPosition(flowX, flowY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y);
|
||||
}
|
||||
|
||||
export function neFlowPosition(apiX: number, apiY: number): { x: number; y: number } {
|
||||
return iconFlowPosition(apiX, apiY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
|
||||
}
|
||||
|
||||
export function neApiPosition(flowX: number, flowY: number): { x: number; y: number } {
|
||||
return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
|
||||
}
|
||||
|
||||
export function isRegionFabricId(id: string): boolean {
|
||||
return String(id || "").startsWith("region:");
|
||||
}
|
||||
227
web/src/features/topology/lib/treeUtils.ts
Normal file
227
web/src/features/topology/lib/treeUtils.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import type { TopologyTreeFolderItem, TopologyTreeViewItem, TopologyWorldTransform } from "../../../types";
|
||||
import { WORLD_MAP_ENABLED } from "./constants";
|
||||
|
||||
export { WORLD_MAP_ENABLED };
|
||||
|
||||
export function findViewInRegion(
|
||||
regions: TopologyTreeFolderItem[],
|
||||
viewId: string,
|
||||
): { region: TopologyTreeFolderItem; view: TopologyTreeViewItem } | null {
|
||||
for (const region of regions) {
|
||||
const view = (region.views || []).find((v) => v.id === viewId);
|
||||
if (view) return { region, view };
|
||||
const nested = findViewInRegion(region.children || [], viewId);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findFolderInTree(
|
||||
folders: TopologyTreeFolderItem[],
|
||||
folderId: string,
|
||||
): TopologyTreeFolderItem | null {
|
||||
for (const folder of folders) {
|
||||
if (folder.id === folderId) return folder;
|
||||
const nested = findFolderInTree(folder.children || [], folderId);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function indexFoldersById(
|
||||
folders: TopologyTreeFolderItem[],
|
||||
acc: Map<string, TopologyTreeFolderItem> = new Map(),
|
||||
): Map<string, TopologyTreeFolderItem> {
|
||||
for (const folder of folders) {
|
||||
acc.set(folder.id, folder);
|
||||
indexFoldersById(folder.children || [], acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
export function folderPathIds(folders: TopologyTreeFolderItem[], folderId: string): string[] {
|
||||
const byId = indexFoldersById(folders);
|
||||
const path: string[] = [];
|
||||
let cur: string | undefined = folderId;
|
||||
const seen = new Set<string>();
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
path.push(cur);
|
||||
const folder = byId.get(cur);
|
||||
const parent = String(folder?.parent_id || "").trim();
|
||||
if (!folder || !parent || !byId.has(parent)) break;
|
||||
cur = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function folderPathFolders(
|
||||
folders: TopologyTreeFolderItem[],
|
||||
folderId: string,
|
||||
): TopologyTreeFolderItem[] {
|
||||
if (!folderId) return [];
|
||||
const byId = indexFoldersById(folders);
|
||||
return folderPathIds(folders, folderId)
|
||||
.map((id) => byId.get(id))
|
||||
.filter((f): f is TopologyTreeFolderItem => Boolean(f))
|
||||
.reverse();
|
||||
}
|
||||
|
||||
export function isUmeWorldContainer(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||
if (!folder) return false;
|
||||
const ext = String(folder.external_ref || "").trim();
|
||||
return ext === "ume:world" || folder.name === "UME World";
|
||||
}
|
||||
|
||||
export function isUmeWorldNavFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||
if (!folder) return false;
|
||||
const ext = String(folder.external_ref || "").trim();
|
||||
if (isUmeWorldContainer(folder) || ext === "ume:world:drill") return true;
|
||||
return Boolean(ext);
|
||||
}
|
||||
|
||||
export function isWorldDrillFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||
if (!folder) return false;
|
||||
const ext = String(folder.external_ref || "").trim();
|
||||
return ext === "ume:world:drill" || (folder.name === "World" && Boolean(folder.is_system));
|
||||
}
|
||||
|
||||
export function isWorldFlatViewName(name: string | undefined | null): boolean {
|
||||
const n = String(name || "").trim();
|
||||
return n === "世界地图" || n === "完整世界地图" || n === "World map";
|
||||
}
|
||||
|
||||
export function isManualRootMapName(name: string | undefined | null): boolean {
|
||||
const n = String(name || "").trim();
|
||||
return n === "根图" || n === "Root map";
|
||||
}
|
||||
|
||||
export function displayViewName(name: string | undefined | null, t: (key: string) => string): string {
|
||||
if (isWorldFlatViewName(name)) return t("topology.worldMapName");
|
||||
if (isManualRootMapName(name)) return t("topology.rootMapName");
|
||||
return String(name || "").trim();
|
||||
}
|
||||
|
||||
export function isUmeStructuralFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||
if (!folder) return false;
|
||||
if (isUmeWorldContainer(folder) || isWorldDrillFolder(folder)) return true;
|
||||
const ext = String(folder.external_ref || "").trim();
|
||||
return ext === "ume:world" || ext === "ume:world:drill";
|
||||
}
|
||||
|
||||
export function isManualRootMapFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||
if (!folder || isUmeStructuralFolder(folder)) return false;
|
||||
return Boolean(folder.is_system) && !String(folder.external_ref || "").trim();
|
||||
}
|
||||
|
||||
export function isUmeSyncedSubRegion(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||
if (!folder || isUmeStructuralFolder(folder)) return false;
|
||||
return Boolean(String(folder.external_ref || "").trim());
|
||||
}
|
||||
|
||||
export function worldVisualLodFromZoom(zoom: number): "dot" | "pin" | "full" {
|
||||
if (zoom < 0.04) return "dot";
|
||||
if (zoom < 0.12) return "pin";
|
||||
return "full";
|
||||
}
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export function isRegionCanvasFolder(
|
||||
folder: TopologyTreeFolderItem | null | undefined,
|
||||
rootId?: string,
|
||||
): boolean {
|
||||
if (!folder) return false;
|
||||
if (isUmeWorldContainer(folder)) return false;
|
||||
const rid = String(rootId || "").trim();
|
||||
const parent = String(folder.parent_id || "").trim();
|
||||
if (rid && parent === rid) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function regionDisplayName(
|
||||
region: TopologyTreeFolderItem | null | undefined,
|
||||
t?: (key: string) => string,
|
||||
): string {
|
||||
if (!region) return "";
|
||||
const raw = String(region.name || "").trim();
|
||||
if (t && isManualRootMapName(raw)) return t("topology.rootMapName");
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function folderNeCount(folder: TopologyTreeFolderItem | null | undefined): number {
|
||||
if (!folder) return 0;
|
||||
return Math.max(0, Math.floor(Number(folder.ne_count) || 0));
|
||||
}
|
||||
|
||||
export function formatNeCount(count: number): string {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
return `${n}N`;
|
||||
}
|
||||
|
||||
export function canvasBgLuminance(bg: string): number {
|
||||
const hex = String(bg || "").replace("#", "").trim();
|
||||
if (hex.length !== 6) return 0.1;
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
if (![r, g, b].every((n) => Number.isFinite(n))) return 0.1;
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
}
|
||||
|
||||
export function canvasFloatChromeVars(bg: string): CSSProperties {
|
||||
const light = canvasBgLuminance(bg) > 0.55;
|
||||
if (light) {
|
||||
return {
|
||||
"--topo-float-bg": "rgba(255, 255, 255, 0.92)",
|
||||
"--topo-float-border": "rgba(15, 23, 42, 0.08)",
|
||||
"--topo-float-shadow": "0 8px 24px rgba(15, 23, 42, 0.12)",
|
||||
"--topo-float-fg": "#0f172a",
|
||||
"--topo-float-muted": "#64748b",
|
||||
"--topo-float-btn-bg": "transparent",
|
||||
"--topo-float-btn-border": "transparent",
|
||||
"--topo-float-btn-hover": "rgba(15, 23, 42, 0.06)",
|
||||
"--topo-float-active-bg": "rgba(37, 99, 235, 0.16)",
|
||||
"--topo-float-active-fg": "#1d4ed8",
|
||||
"--topo-float-active-border": "#3b82f6",
|
||||
"--topo-float-control-bg": "transparent",
|
||||
"--topo-float-control-hover": "rgba(15, 23, 42, 0.06)",
|
||||
"--topo-float-control-fill": "#334155",
|
||||
"--topo-float-input-bg": "#ffffff",
|
||||
"--topo-float-input-fg": "#0f172a",
|
||||
"--topo-float-input-border": "rgba(15, 23, 42, 0.16)",
|
||||
} as CSSProperties;
|
||||
}
|
||||
return {
|
||||
"--topo-float-bg": "rgba(15, 23, 42, 0.92)",
|
||||
"--topo-float-border": "rgba(148, 163, 184, 0.18)",
|
||||
"--topo-float-shadow": "0 8px 24px rgba(0, 0, 0, 0.35)",
|
||||
"--topo-float-fg": "#e2e8f0",
|
||||
"--topo-float-muted": "#94a3b8",
|
||||
"--topo-float-btn-bg": "transparent",
|
||||
"--topo-float-btn-border": "transparent",
|
||||
"--topo-float-btn-hover": "rgba(30, 41, 59, 0.9)",
|
||||
"--topo-float-active-bg": "rgba(37, 99, 235, 0.28)",
|
||||
"--topo-float-active-fg": "#93c5fd",
|
||||
"--topo-float-active-border": "#3b82f6",
|
||||
"--topo-float-control-bg": "transparent",
|
||||
"--topo-float-control-hover": "rgba(30, 41, 59, 0.9)",
|
||||
"--topo-float-control-fill": "#cbd5e1",
|
||||
"--topo-float-input-bg": "#0b1220",
|
||||
"--topo-float-input-fg": "#e2e8f0",
|
||||
"--topo-float-input-border": "rgba(148, 163, 184, 0.28)",
|
||||
} as CSSProperties;
|
||||
}
|
||||
35
web/src/features/topology/lib/vendorTone.ts
Normal file
35
web/src/features/topology/lib/vendorTone.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import { VENDOR_TONE_KEYS, type VendorColors, type VendorToneKey } from "./displayPrefs";
|
||||
|
||||
export type { VendorToneKey, VendorColors };
|
||||
|
||||
export function nodeIconTone(
|
||||
vendor: string,
|
||||
managedNeId: string,
|
||||
umeNeId = "",
|
||||
): VendorToneKey {
|
||||
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";
|
||||
}
|
||||
|
||||
export function vendorColorForNode(data: NeNodeData, colors: VendorColors): string {
|
||||
if (data.kind === "region") return "#38bdf8";
|
||||
return colors[nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id)] || colors.gray;
|
||||
}
|
||||
|
||||
export { VENDOR_TONE_KEYS };
|
||||
73
web/src/features/topology/lib/worldGraph.ts
Normal file
73
web/src/features/topology/lib/worldGraph.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type {
|
||||
TopologyViewEdgeItem,
|
||||
TopologyViewGraph,
|
||||
TopologyViewNodeItem,
|
||||
} from "../../../types";
|
||||
import { WORLD_FLAT_ACCUM_CAP } from "./constants";
|
||||
|
||||
/** Merge newly fetched flat LOD into the existing cache so visited areas stay. */
|
||||
export function mergeFlatWorldGraph(
|
||||
prev: TopologyViewGraph | undefined,
|
||||
next: TopologyViewGraph,
|
||||
opts?: { centerX?: number; centerY?: number },
|
||||
): TopologyViewGraph {
|
||||
const scatter = next.scatter?.length ? next.scatter : prev?.scatter;
|
||||
if (!prev?.nodes?.length) {
|
||||
return scatter?.length && !next.scatter?.length ? { ...next, scatter } : next;
|
||||
}
|
||||
if (!next?.nodes?.length) {
|
||||
return {
|
||||
...prev,
|
||||
view: next.view || prev.view,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
scatter: scatter || [],
|
||||
world_transform: next.world_transform ?? prev.world_transform,
|
||||
truncated: Boolean(next.truncated || prev.truncated),
|
||||
truncate_reason: next.truncate_reason || prev.truncate_reason || "",
|
||||
outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers,
|
||||
};
|
||||
}
|
||||
|
||||
const nodeMap = new Map<string, TopologyViewNodeItem>();
|
||||
for (const n of prev.nodes) nodeMap.set(String(n.fabric_node_id), n);
|
||||
for (const n of next.nodes) nodeMap.set(String(n.fabric_node_id), n);
|
||||
|
||||
const edgeMap = new Map<string, TopologyViewEdgeItem>();
|
||||
for (const e of prev.edges || []) edgeMap.set(String(e.id), e);
|
||||
for (const e of next.edges || []) edgeMap.set(String(e.id), e);
|
||||
|
||||
let nodes = [...nodeMap.values()];
|
||||
const cx = opts?.centerX;
|
||||
const cy = opts?.centerY;
|
||||
if (nodes.length > WORLD_FLAT_ACCUM_CAP && cx != null && cy != null) {
|
||||
const prefer = new Set(next.nodes.map((n) => String(n.fabric_node_id)));
|
||||
const kept = next.nodes.slice();
|
||||
const extras = nodes
|
||||
.filter((n) => !prefer.has(String(n.fabric_node_id)))
|
||||
.sort((a, b) => {
|
||||
const da = (Number(a.x) - cx) ** 2 + (Number(a.y) - cy) ** 2;
|
||||
const db = (Number(b.x) - cx) ** 2 + (Number(b.y) - cy) ** 2;
|
||||
return da - db;
|
||||
});
|
||||
const room = Math.max(0, WORLD_FLAT_ACCUM_CAP - kept.length);
|
||||
nodes = kept.concat(extras.slice(0, room));
|
||||
}
|
||||
|
||||
const idSet = new Set(nodes.map((n) => String(n.fabric_node_id)));
|
||||
const edges = [...edgeMap.values()].filter(
|
||||
(e) => idSet.has(String(e.a_node_id)) && idSet.has(String(e.b_node_id)),
|
||||
);
|
||||
|
||||
return {
|
||||
...next,
|
||||
view: next.view || prev.view,
|
||||
nodes,
|
||||
edges,
|
||||
world_transform: next.world_transform ?? prev.world_transform,
|
||||
scatter: scatter || [],
|
||||
truncated: Boolean(prev.truncated || next.truncated),
|
||||
truncate_reason: next.truncate_reason || prev.truncate_reason || "",
|
||||
outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue