feat(topology): add network topology page; tighten config save check

- Interactive SRv6 topology canvas (@xyflow/react + d3-force auto-layout),
  custom nodes/edges, detail sheet, search and relayout controls
- Strict success-flag validation on /api/config save responses
This commit is contained in:
2026-08-24 23:59:00 +08:00
parent 4531e5366b
commit 3f0b20e757
10 changed files with 1114 additions and 37 deletions
+94
View File
@@ -0,0 +1,94 @@
import { memo } from 'react'
import {
BaseEdge,
EdgeLabelRenderer,
getSmoothStepPath,
useReactFlow,
type EdgeProps,
type Edge,
} from '@xyflow/react'
import { formatNanoDelay } from '@/lib/format'
export interface LinkEdgeData {
delay: number
cost: number
[key: string]: unknown
}
export type LinkEdgeType = Edge<LinkEdgeData, 'link'>
function delayColor(delayNs: number): { stroke: string; badge: string } {
const ms = delayNs / 1_000_000
if (delayNs <= 0 || !Number.isFinite(delayNs)) {
return { stroke: 'hsl(var(--muted-foreground))', badge: 'bg-muted text-muted-foreground' }
}
if (ms < 1) {
return { stroke: '#10b981', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' }
}
if (ms < 20) {
return { stroke: '#f59e0b', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' }
}
return { stroke: '#f43f5e', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400' }
}
function LinkEdgeComponent({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}: EdgeProps<LinkEdgeType>) {
const { setEdges } = useReactFlow()
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 16,
})
const delay = data?.delay ?? 0
const { stroke, badge } = delayColor(delay)
const showLabel = delay > 0 && Number.isFinite(delay)
return (
<>
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: selected ? 2.5 : 1.75,
stroke,
opacity: selected ? 1 : 0.85,
}}
/>
{showLabel && (
<EdgeLabelRenderer>
<div
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
}}
className="pointer-events-none absolute"
>
<button
type="button"
onClick={() => setEdges((eds) => eds.filter((e) => e.id !== id))}
className={`pointer-events-auto cursor-pointer rounded-md border border-border/60 px-1.5 py-px font-mono text-[10px] font-semibold tabular-nums shadow-2xs backdrop-blur-xs transition-opacity hover:opacity-70 ${badge}`}
title="点击隐藏该链路"
>
{formatNanoDelay(delay)}
</button>
</div>
</EdgeLabelRenderer>
)}
</>
)
}
export const LinkEdge = memo(LinkEdgeComponent)