diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx index 3ad6f22..5ef5da0 100644 --- a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx +++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx @@ -492,21 +492,212 @@ const parseTunnelQualityChainDetails = ( return {}; }; -const candidateRoleLabel = (role: string) => { - switch (role) { - case "entry": - return "入口"; - case "middle": - return "中转"; - case "exit": - return "出口"; - case "target": - return "测试目标"; - default: - return role; - } +type TunnelTopologyHop = TunnelQualityHopApiItem & { + errorMessage?: string; }; +interface TunnelTopologyPath { + key: string; + hops: TunnelTopologyHop[]; + alternativeNodeIndex?: number; +} + +const tunnelTopologyHopKey = (fromNodeId: number, toNodeId: number) => + `${fromNodeId}:${toNodeId}`; + +const buildTunnelTopologyPaths = ( + details: TunnelQualityChainDetailsApiItem, +): TunnelTopologyPath[] => { + const primaryHops = details.primaryPath ?? []; + const candidates = details.candidateHops ?? []; + + if (primaryHops.length === 0) { + const publicCandidates = candidates.filter( + (candidate) => candidate.toRole === "target", + ); + const selected = publicCandidates.find((candidate) => candidate.selected); + const paths: TunnelTopologyPath[] = []; + + if (selected) { + paths.push({ key: "primary-public", hops: [selected] }); + } + for (const candidate of publicCandidates) { + if (candidate.selected) continue; + paths.push({ + key: `alternative-public-${candidate.fromNodeId}`, + hops: [candidate], + alternativeNodeIndex: 0, + }); + } + + return paths; + } + + const primaryNodeIds = [ + primaryHops[0].fromNodeId, + ...primaryHops.map((hop) => hop.toNodeId), + ]; + const internalCandidates = candidates.filter( + (candidate) => candidate.toRole !== "target", + ); + const candidateHopMap = new Map(); + const alternativeNodes = new Map< + string, + { column: number; nodeId: number } + >(); + + for (const candidate of internalCandidates) { + candidateHopMap.set( + tunnelTopologyHopKey(candidate.fromNodeId, candidate.toNodeId), + candidate, + ); + + const sourceColumn = candidate.hopIndex; + const targetColumn = candidate.hopIndex + 1; + + if ( + sourceColumn >= 0 && + sourceColumn < primaryNodeIds.length && + candidate.fromNodeId !== primaryNodeIds[sourceColumn] + ) { + alternativeNodes.set(`${sourceColumn}:${candidate.fromNodeId}`, { + column: sourceColumn, + nodeId: candidate.fromNodeId, + }); + } + if ( + targetColumn >= 0 && + targetColumn < primaryNodeIds.length && + candidate.toNodeId !== primaryNodeIds[targetColumn] + ) { + alternativeNodes.set(`${targetColumn}:${candidate.toNodeId}`, { + column: targetColumn, + nodeId: candidate.toNodeId, + }); + } + } + + const paths: TunnelTopologyPath[] = [{ key: "primary", hops: primaryHops }]; + + for (const alternative of alternativeNodes.values()) { + const nodeIds = [...primaryNodeIds]; + + nodeIds[alternative.column] = alternative.nodeId; + const hops: TunnelTopologyHop[] = []; + + for (let index = 0; index < nodeIds.length - 1; index += 1) { + const fromNodeId = nodeIds[index]; + const toNodeId = nodeIds[index + 1]; + const usesPrimaryEdge = + fromNodeId === primaryNodeIds[index] && + toNodeId === primaryNodeIds[index + 1]; + const hop = usesPrimaryEdge + ? primaryHops[index] + : candidateHopMap.get(tunnelTopologyHopKey(fromNodeId, toNodeId)); + + if (!hop) break; + hops.push(hop); + } + + if (hops.length === primaryHops.length) { + paths.push({ + key: `alternative-${alternative.column}-${alternative.nodeId}`, + hops, + alternativeNodeIndex: alternative.column, + }); + } + } + + return paths; +}; + +function TunnelTopologyPathRow({ path }: { path: TunnelTopologyPath }) { + return ( +
+ {path.hops.map((hop, index) => { + const hasError = + Boolean(hop.errorMessage) || hop.latency < 0 || hop.loss > 0; + const colorClass = + hop.latency < 0 || hop.errorMessage + ? "text-danger" + : hop.loss > 0 + ? "text-warning" + : "text-success"; + const borderColor = hasError ? "border-danger" : ""; + + return ( + + {index === 0 ? ( + + ) : null} +
+ + {hop.latency >= 0 && !hop.errorMessage + ? `${hop.latency.toFixed(0)}ms` + : "超时"} + +
+ +
+ 0 ? "text-warning" : "text-default-400"}`} + > + {hop.errorMessage ? "探测失败" : `${hop.loss.toFixed(0)}% 丢包`} + +
+ +
+ ); + })} +
+ ); +} + +function TopologyNodeChip({ + name, + isAlternative = false, + borderColor = "", +}: { + name: string; + isAlternative?: boolean; + borderColor?: string; +}) { + return ( + + + {name} + {isAlternative ? ( + + 备选 + + ) : null} + + + ); +} + const ForwardingChainTopology = React.memo(function ForwardingChainTopology({ hopsStr, }: { @@ -516,13 +707,12 @@ const ForwardingChainTopology = React.memo(function ForwardingChainTopology({ () => parseTunnelQualityChainDetails(hopsStr), [hopsStr], ); - const primaryHops = details.primaryPath ?? []; - const alternativeHops = useMemo( - () => (details.candidateHops ?? []).filter((hop) => !hop.selected), - [details.candidateHops], + const topologyPaths = useMemo( + () => buildTunnelTopologyPaths(details), + [details], ); - if (primaryHops.length === 0 && alternativeHops.length === 0) return null; + if (topologyPaths.length === 0) return null; return ( @@ -533,133 +723,25 @@ const ForwardingChainTopology = React.memo(function ForwardingChainTopology({ - {primaryHops.length > 0 ? ( -
- {primaryHops.map((hop, index) => { - const hasError = hop.latency < 0 || hop.loss > 0; - const colorClass = - hop.latency < 0 - ? "text-danger" - : hop.loss > 0 - ? "text-warning" - : "text-success"; - const borderColor = hasError ? "border-danger" : ""; - - return ( - - {index === 0 ? ( - - {hop.fromNodeName} - - ) : null} -
- - {hop.latency >= 0 - ? `${hop.latency.toFixed(0)}ms` - : "超时"} - -
- -
- 0 ? "text-warning" : "text-default-400"}`} - > - {hop.loss.toFixed(0)}% 丢包 - -
- - {hop.toNodeName} - -
- ); - })} -
- ) : null} - - {alternativeHops.length > 0 ? ( -
0 ? "mt-3 border-t border-divider/60 pt-3" : ""}`} - > -
- - 备选节点实时延迟 - - - {alternativeHops.length} 条候选链路 - +
+ {topologyPaths.map((path, index) => ( +
+
-
- {alternativeHops.map((hop) => ( - - ))} -
-
- ) : null} + ))} +
); }); -function CandidateHopLatency({ - hop, -}: { - hop: TunnelQualityCandidateHopApiItem; -}) { - const hasError = Boolean(hop.errorMessage) || hop.latency < 0; - - return ( -
-
- - {hop.fromNodeName} - - - - {hop.toNodeName} - - - - -
-
- - {candidateRoleLabel(hop.fromRole)} → {candidateRoleLabel(hop.toRole)} - - {hasError ? ( - - {hop.errorMessage || "探测失败"} - - ) : ( - 0 ? "text-warning" : "text-default-500"}`} - > - {hop.loss.toFixed(0)}% 丢包 - - )} -
-
- ); -} - export function TunnelMonitorView({ viewMode = "grid", }: TunnelMonitorViewProps) {