netx/web/src/features/topology/lib/linkDisplay.ts
oliver 0d894efe5e 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>
2026-10-12 03:17:51 +08:00

214 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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];
}