import type { MonitorTunnelApiItem, TunnelMetricApiItem, TunnelDiagnosisApiItem, } from "@/api/types"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { RefreshCw, ArrowLeft, Activity, Zap, Globe, ArrowRightLeft, Wifi, WifiOff, Stethoscope, } from "lucide-react"; import toast from "react-hot-toast"; import { getMonitorTunnels, getTunnelMetrics, diagnoseTunnel, } from "@/api"; import { diagnoseTunnelStream } from "@/api/diagnosis-stream"; import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, } from "@/shadcn-bridge/heroui/table"; interface TunnelMonitorViewProps { viewMode?: "list" | "grid"; } const METRICS_MAX_ROWS = 5000; interface TunnelQuality { loading: boolean; entryToExitLatency?: number; exitToBingLatency?: number; entryToExitLoss?: number; exitToBingLoss?: number; results?: TunnelDiagnosisApiItem[]; timestamp?: number; error?: string; } const formatTimestamp = (ts: number, rangeMs?: number): string => { const date = new Date(ts); const includeDate = (rangeMs ?? 0) >= 24 * 60 * 60 * 1000; if (includeDate) { return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", }); }; const formatBytes = (bytes: number): string => { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) { const [tunnels, setTunnels] = useState([]); const [tunnelsLoading, setTunnelsLoading] = useState(false); const [tunnelsError, setTunnelsError] = useState(null); const [accessDenied, setAccessDenied] = useState(null); // Detail view state const [detailTunnelId, setDetailTunnelId] = useState(null); const [tunnelMetrics, setTunnelMetrics] = useState([]); const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false); const [tunnelMetricsError, setTunnelMetricsError] = useState(null); const [, setTunnelMetricsTruncated] = useState(false); const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000); // Tunnel quality (diagnosis) state const [tunnelQualities, setTunnelQualities] = useState>({}); const diagnosisAbortRef = useRef>({}); // Cleanup abort controllers on unmount useEffect(() => { return () => { Object.values(diagnosisAbortRef.current).forEach((c) => c.abort()); diagnosisAbortRef.current = {}; }; }, []); const loadTunnels = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; if (!silent) setTunnelsLoading(true); try { const response = await getMonitorTunnels(); if (response.code === 0 && response.data) { setAccessDenied(null); setTunnelsError(null); setTunnels(response.data); return; } if (response.code === 403) { setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); setTunnelsError(null); setTunnels([]); return; } setTunnelsError(response.msg || "加载隧道列表失败"); if (!silent) toast.error(response.msg || "加载隧道列表失败"); } catch { if (!silent) { setTunnelsError("加载隧道列表失败"); toast.error("加载隧道列表失败"); } } finally { if (!silent) setTunnelsLoading(false); } }, []); useEffect(() => { void loadTunnels(); }, [loadTunnels]); useEffect(() => { const timer = window.setInterval(() => { void loadTunnels({ silent: true }); }, 60_000); return () => window.clearInterval(timer); }, [loadTunnels]); // Load tunnel metrics for detail view const loadTunnelMetrics = useCallback( async (tunnelId: number, options?: { silent?: boolean }) => { const silent = options?.silent ?? false; if (!silent) setTunnelMetricsLoading(true); try { const end = Date.now(); const start = end - tunnelRangeMs; const response = await getTunnelMetrics(tunnelId, start, end); if (response.code === 0 && Array.isArray(response.data)) { setAccessDenied(null); setTunnelMetricsError(null); setTunnelMetricsTruncated(response.data.length >= METRICS_MAX_ROWS); const ordered = [...response.data].sort( (a, b) => a.timestamp - b.timestamp, ); setTunnelMetrics(ordered); return; } if (response.code === 403) { setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); setTunnelMetricsTruncated(false); setTunnelMetricsError(null); return; } setTunnelMetricsTruncated(false); setTunnelMetricsError(response.msg || "加载隧道指标失败"); if (!silent) toast.error(response.msg || "加载隧道指标失败"); } catch { setTunnelMetricsTruncated(false); if (!silent) setTunnelMetricsError("加载隧道指标失败"); } finally { if (!silent) setTunnelMetricsLoading(false); } }, [tunnelRangeMs], ); useEffect(() => { if (detailTunnelId) { void loadTunnelMetrics(detailTunnelId); } }, [detailTunnelId, loadTunnelMetrics]); useEffect(() => { if (!detailTunnelId) return; const timer = window.setInterval(() => { void loadTunnelMetrics(detailTunnelId, { silent: true }); }, 30_000); return () => window.clearInterval(timer); }, [detailTunnelId, loadTunnelMetrics]); // Diagnose tunnel quality const diagnoseTunnelQuality = useCallback(async (tunnelId: number) => { // Abort if already running if (diagnosisAbortRef.current[tunnelId]) { diagnosisAbortRef.current[tunnelId].abort(); } const abortController = new AbortController(); diagnosisAbortRef.current[tunnelId] = abortController; setTunnelQualities((prev) => ({ ...prev, [tunnelId]: { loading: true }, })); try { // Try stream first const results: TunnelDiagnosisApiItem[] = []; const streamResult = await diagnoseTunnelStream( tunnelId, { onItem: (payload) => { results.push(payload.result); }, onError: (msg) => { setTunnelQualities((prev) => ({ ...prev, [tunnelId]: { loading: false, error: msg }, })); }, }, abortController.signal, ); if (streamResult.fallback) { // Fallback to non-stream API try { const response = await diagnoseTunnel(tunnelId); if (response.code === 0 && response.data?.results) { const apiResults = response.data.results; const quality = extractQualityFromResults(apiResults); setTunnelQualities((prev) => ({ ...prev, [tunnelId]: { loading: false, ...quality, results: apiResults, timestamp: Date.now(), }, })); } else { setTunnelQualities((prev) => ({ ...prev, [tunnelId]: { loading: false, error: response.msg || "诊断失败" }, })); } } catch { setTunnelQualities((prev) => ({ ...prev, [tunnelId]: { loading: false, error: "诊断请求失败" }, })); } return; } // Process stream results if (results.length > 0) { const quality = extractQualityFromResults(results); setTunnelQualities((prev) => ({ ...prev, [tunnelId]: { loading: false, ...quality, results, timestamp: Date.now(), }, })); } else { setTunnelQualities((prev) => ({ ...prev, [tunnelId]: { loading: false, error: "未获取到诊断结果" }, })); } } catch { if (!abortController.signal.aborted) { setTunnelQualities((prev) => ({ ...prev, [tunnelId]: { loading: false, error: "诊断失败" }, })); } } finally { delete diagnosisAbortRef.current[tunnelId]; } }, []); const extractQualityFromResults = ( results: TunnelDiagnosisApiItem[], ): Pick => { // The diagnosis results contain hop-by-hop tests // We look for entry→exit (hop between entry and exit nodes) // and exit→Bing (the last hop to external target like bing.com) let entryToExitLatency: number | undefined; let exitToBingLatency: number | undefined; let entryToExitLoss: number | undefined; let exitToBingLoss: number | undefined; for (const r of results) { if (!r.success) continue; // Entry to Exit: chainType transitions from 1 (entry) to 3 (exit) if (r.fromChainType === 1 && r.toChainType === 3) { entryToExitLatency = r.averageTime; entryToExitLoss = r.packetLoss; } // Or if it's a mid-chain to exit if (r.fromChainType === 2 && r.toChainType === 3) { // Use this if no direct entry→exit if (entryToExitLatency === undefined) { entryToExitLatency = r.averageTime; entryToExitLoss = r.packetLoss; } } // Exit to external target (Bing / external) if (r.toChainType === undefined || r.toChainType === 0) { // This typically means it's the exit node testing external if (r.fromChainType === 3) { exitToBingLatency = r.averageTime; exitToBingLoss = r.packetLoss; } } } // If no chainType-based matching, use position-based heuristics if (entryToExitLatency === undefined && exitToBingLatency === undefined) { const successResults = results.filter((r) => r.success); if (successResults.length >= 2) { entryToExitLatency = successResults[0].averageTime; entryToExitLoss = successResults[0].packetLoss; exitToBingLatency = successResults[successResults.length - 1].averageTime; exitToBingLoss = successResults[successResults.length - 1].packetLoss; } else if (successResults.length === 1) { entryToExitLatency = successResults[0].averageTime; entryToExitLoss = successResults[0].packetLoss; } } return { entryToExitLatency, exitToBingLatency, entryToExitLoss, exitToBingLoss }; }; // Chart data const tunnelChartData = tunnelMetrics.map((m) => ({ time: formatTimestamp(m.timestamp, tunnelRangeMs), bytesIn: m.bytesIn, bytesOut: m.bytesOut, connections: m.connections, })); const tunnelYAxisTickFormatter = (value: unknown) => { const n = Number(value); if (!Number.isFinite(n)) return ""; return formatBytes(n); }; const tunnelTooltipFormatter = (value: unknown) => { const n = Number(value); if (!Number.isFinite(n)) return "-"; return formatBytes(n); }; const detailTunnel = detailTunnelId != null ? tunnels.find((t) => t.id === detailTunnelId) : null; // Aggregate stats const tunnelStats = useMemo(() => { const enabled = tunnels.filter((t) => t.status === 1).length; const disabled = tunnels.length - enabled; const diagnosed = Object.keys(tunnelQualities).filter((k) => { const q = tunnelQualities[Number(k)]; return q && !q.loading && !q.error; }).length; return { total: tunnels.length, enabled, disabled, diagnosed }; }, [tunnels, tunnelQualities]); // ===================== // RENDER // ===================== if (accessDenied) { return (

监控权限

{accessDenied}
如需使用监控功能,请联系管理员在用户页面授予监控权限。
); } // ===== DETAIL VIEW ===== if (detailTunnelId && detailTunnel) { const quality = tunnelQualities[detailTunnelId]; return (
{/* Header */}

{detailTunnel.name}

{detailTunnel.status === 1 ? "启用" : "禁用"}
{/* Quality KPI Cards */}
入口 → 出口 延迟 {quality?.loading ? "检测中..." : quality?.entryToExitLatency !== undefined ? `${quality.entryToExitLatency.toFixed(0)}ms` : "-"} 出口 → Bing 延迟 {quality?.loading ? "检测中..." : quality?.exitToBingLatency !== undefined ? `${quality.exitToBingLatency.toFixed(0)}ms` : "-"} 入口 → 出口 丢包 0 ? "text-warning" : ""}`}> {quality?.loading ? "检测中..." : quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"} 出口 → Bing 丢包 0 ? "text-warning" : ""}`}> {quality?.loading ? "检测中..." : quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
{/* Diagnose Button */}
{quality?.timestamp && ( 上次检测: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} )} {quality?.error && ( {quality.error} )}
{/* Diagnosis Details */} {quality?.results && quality.results.length > 0 && (

诊断详情

描述 节点 目标 延迟 丢包 状态 {quality.results.map((r, idx) => ( {r.description || "-"} {r.nodeName || "-"} {r.targetIp || "-"}{r.targetPort ? `:${r.targetPort}` : ""} {r.averageTime !== undefined ? `${r.averageTime.toFixed(0)}ms` : "-"} {r.packetLoss !== undefined ? `${r.packetLoss.toFixed(1)}%` : "-"} {r.success ? "成功" : "失败"} ))}
)} {/* Tunnel traffic chart */}

隧道流量趋势

{tunnelMetricsLoading ? (
) : tunnelMetricsError ? (
{tunnelMetricsError}
) : tunnelMetrics.length > 0 ? (
) : (
暂无指标数据
)}
); } // ===== LIST/GRID VIEW ===== return (
隧道 {tunnelStats.enabled}/{tunnelStats.total} {tunnelStats.diagnosed > 0 && ( 已诊断 {tunnelStats.diagnosed} )}
{tunnelsError ? (
{tunnelsError}
) : null} {viewMode === "grid" ? (
{tunnels.map((tunnel) => { const quality = tunnelQualities[tunnel.id]; const isEnabled = tunnel.status === 1; const overallQuality = quality?.entryToExitLatency !== undefined ? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0) : null; return ( setDetailTunnelId(tunnel.id)} > {/* Top gradient bar */}
{/* Decorative background glow */}

{tunnel.name}

{isEnabled ? "启用" : "禁用"}
{overallQuality && ( {overallQuality.text} )}
{/* Quality metrics */}
入口→出口
{quality?.loading ? ( ) : quality?.entryToExitLatency !== undefined ? ( `${quality.entryToExitLatency.toFixed(0)}ms` ) : "-"}
出口→Bing
{quality?.loading ? ( ) : quality?.exitToBingLatency !== undefined ? ( `${quality.exitToBingLatency.toFixed(0)}ms` ) : "-"}
{/* Action area */}
{quality?.error ? ( {quality.error} ) : quality?.timestamp ? ( {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} ) : ( 未检测 )}
); })}
) : ( 状态 名称 入口→出口 出口→Bing 质量 操作 {tunnels.map((tunnel) => { const quality = tunnelQualities[tunnel.id]; const isEnabled = tunnel.status === 1; const overallQuality = quality?.entryToExitLatency !== undefined ? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0) : null; return ( setDetailTunnelId(tunnel.id)}>
{isEnabled ? ( ) : ( )}
{tunnel.name} {quality?.loading ? ( ) : quality?.entryToExitLatency !== undefined ? ( `${quality.entryToExitLatency.toFixed(0)}ms` ) : "-"} {quality?.loading ? ( ) : quality?.exitToBingLatency !== undefined ? ( `${quality.exitToBingLatency.toFixed(0)}ms` ) : "-"} {overallQuality ? ( {overallQuality.text} ) : ( - )}
); })}
)}
); }