mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 13:20:52 +08:00
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>
214 lines
6.1 KiB
TypeScript
214 lines
6.1 KiB
TypeScript
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];
|
||
}
|