import type { MonitorTunnelApiItem, TunnelMetricApiItem, TunnelQualityApiItem, TunnelQualityHopApiItem, } from "@/api/types"; import React, { 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, ArrowRight, } from "lucide-react"; import toast from "react-hot-toast"; import { getMonitorTunnels, getTunnelMetrics, getMonitorTunnelQuality, getMonitorTunnelQualityHistory, getConfigByName, } from "@/api"; 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 QUALITY_POLL_INTERVAL = 1_000; // 1 second const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled"; const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT = "monitorTunnelQualityEnabledChanged"; const DEFAULT_PROBE_TARGET_LABEL = "www.bing.com:443"; const probeTargetLabel = (quality?: TunnelQualityApiItem | null) => { if (!quality?.probeTargetHost || !quality.probeTargetPort) { return DEFAULT_PROBE_TARGET_LABEL; } const host = quality.probeTargetHost.includes(":") ? `[${quality.probeTargetHost}]` : quality.probeTargetHost; return `${host}:${quality.probeTargetPort}`; }; 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", second: "2-digit", }); }; function LatencyDisplay({ value, loading, }: { value?: number; loading?: boolean; }) { if (loading) { return ; } if (value === undefined || value < 0) { return -; } const ms = value.toFixed(0); let colorClass = "text-success"; if (value > 200) colorClass = "text-danger"; else if (value > 100) colorClass = "text-warning"; else if (value > 50) colorClass = "text-primary"; return ( {ms}ms ); } function LiveDot() { return ( ); } const UPTIME_KUMA_BARS_COUNT = 30; function UptimeHistoryBar({ history = [], type, latestValue, }: { history: TunnelQualityApiItem[]; type: "entryToExit" | "exitToBing"; latestValue?: number; }) { const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => { const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i; if (historyIndex >= 0 && historyIndex < history.length) { return history[historyIndex]; } return null; }); return (
{bars.map((q, idx) => { if (!q) { return (
); } const isEntry = type === "entryToExit"; const latency = isEntry ? q.entryToExitLatency : q.exitToBingLatency; const loss = isEntry ? q.entryToExitLoss : q.exitToBingLoss; const timeStr = new Date(q.timestamp).toLocaleTimeString("zh-CN"); let colorClass = "bg-success"; let statusText = "优秀"; if (q.errorMessage || (loss !== undefined && loss > 0)) { colorClass = "bg-danger"; statusText = q.errorMessage ? "错误" : `丢包 ${loss?.toFixed(1)}%`; } else if (latency < 0) { colorClass = "bg-danger"; statusText = "探测失败"; } else if (latency > 200) { colorClass = "bg-danger"; statusText = "极差"; } else if (latency > 100) { colorClass = "bg-warning"; statusText = "较差"; } const displayLatency = latency >= 0 ? `${latency.toFixed(0)}ms` : "-"; const tooltip = `${timeStr} | ${displayLatency} | ${statusText} | 测试目标: ${probeTargetLabel(q)}`; return (
); })}
); } const TIME_RANGE_OPTIONS = [ { key: String(15 * 60 * 1000), label: "15分钟" }, { key: String(60 * 60 * 1000), label: "1小时" }, { key: String(6 * 60 * 60 * 1000), label: "6小时" }, { key: String(24 * 60 * 60 * 1000), label: "24小时" }, ]; function TimeRangeSelect({ value, onChange, }: { value: number; onChange: (v: number) => void; }) { return ( ); } interface QualityChartCardProps { rangeMs: number; onRangeChange: (v: number) => void; loading: boolean; error: string | null; data: Array<{ time: string; entryToExit: number | null; exitToBing: number | null; }>; tunnelId: number; onRefresh: (id: number) => void; } const QualityChartCard = React.memo(function QualityChartCard({ rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh, }: QualityChartCardProps) { return (

质量趋势

{loading ? (
) : error ? (
{error}
) : data.length > 0 ? (
`${Number(v).toFixed(0)}ms`} /> { const n = Number(value); if (!Number.isFinite(n)) return "-"; const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→测试目标" : name; return [`${n.toFixed(1)}ms`, label]; }} labelStyle={{ color: "#fff" }} />
) : (
暂无质量历史数据
)}
); }); interface TrafficChartCardProps { rangeMs: number; onRangeChange: (v: number) => void; loading: boolean; error: string | null; data: Array<{ time: string; bytesIn: number; bytesOut: number; connections: number; }>; tunnelId: number; onRefresh: (id: number) => void; } const TrafficChartCard = React.memo(function TrafficChartCard({ rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh, }: TrafficChartCardProps) { const yFormatter = (value: unknown) => { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(n) / Math.log(k)); return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; return (

流量趋势

{loading ? (
) : error ? (
{error}
) : data.length > 0 ? (
) : (
暂无流量数据
)}
); }); function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) { if (!hopsStr) return null; let hops: TunnelQualityHopApiItem[] = []; try { hops = JSON.parse(hopsStr); } catch { return null; } if (!Array.isArray(hops) || hops.length === 0) return null; return (

全链路拓扑状态 (实时)

{hops.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} )}
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
0 ? "text-warning" : "text-default-400"}`} > {hop.loss.toFixed(0)}% 丢包
{hop.toNodeName}
); })}
); } 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); // Quality data from backend periodic probing (latest per tunnel) const [qualityMap, setQualityMap] = useState< Record >({}); const [qualityHistoryMap, setQualityHistoryMap] = useState< Record >({}); const initialHistoryFetched = useRef(false); const [qualityLoading, setQualityLoading] = useState(false); const qualityTimerRef = useRef(null); const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] = useState(true); // Detail view state const [detailTunnelId, setDetailTunnelId] = useState(null); // Quality history for chart (mirrors service monitor results) const [qualityHistory, setQualityHistory] = useState( [], ); const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false); const [qualityHistoryError, setQualityHistoryError] = useState( null, ); const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000); // Tunnel traffic metrics for chart const [tunnelMetrics, setTunnelMetrics] = useState([]); const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false); const [tunnelMetricsError, setTunnelMetricsError] = useState( null, ); const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000); // --- Load tunnel list --- 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); } }, []); const loadMonitorTunnelQualityEnabled = useCallback(async () => { try { const response = await getConfigByName( MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY, ); setMonitorTunnelQualityEnabled( typeof response.data?.value === "string" ? response.data.value === "true" : true, ); } catch { setMonitorTunnelQualityEnabled(true); } }, []); useEffect(() => { void loadTunnels(); void loadMonitorTunnelQualityEnabled(); }, [loadMonitorTunnelQualityEnabled, loadTunnels]); useEffect(() => { const timer = window.setInterval(() => { void loadTunnels({ silent: true }); }, 60_000); return () => window.clearInterval(timer); }, [loadTunnels]); useEffect(() => { const handleMonitorTunnelQualityEnabledChanged = (event: Event) => { const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail ?.enabled; if (typeof enabled === "boolean") { setMonitorTunnelQualityEnabled(enabled); if (!enabled) { setQualityLoading(false); } } else { void loadMonitorTunnelQualityEnabled(); } }; window.addEventListener( MONITOR_TUNNEL_QUALITY_ENABLED_EVENT, handleMonitorTunnelQualityEnabledChanged as EventListener, ); return () => { window.removeEventListener( MONITOR_TUNNEL_QUALITY_ENABLED_EVENT, handleMonitorTunnelQualityEnabledChanged as EventListener, ); }; }, [loadMonitorTunnelQualityEnabled]); useEffect(() => { if (tunnels.length > 0 && !initialHistoryFetched.current) { initialHistoryFetched.current = true; const fetchHistory = async () => { const end = Date.now(); // Fetch last 5 minutes (about 30 points) const start = end - 5 * 60 * 1000; const promises = tunnels.map((t) => getMonitorTunnelQualityHistory(t.id, start, end) .then((res) => { if (res.code === 0 && res.data) { return { id: t.id, data: res.data }; } return { id: t.id, data: [] }; }) .catch(() => ({ id: t.id, data: [] })), ); const results = await Promise.all(promises); setQualityHistoryMap((prev) => { const nextMap = { ...prev }; results.forEach((r) => { const existing = nextMap[r.id] || []; const merged = [...r.data, ...existing].sort( (a, b) => a.timestamp - b.timestamp, ); const unique: TunnelQualityApiItem[] = []; for (const item of merged) { if ( unique.length === 0 || unique[unique.length - 1].timestamp !== item.timestamp ) { unique.push(item); } } nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT); }); return nextMap; }); }; void fetchHistory(); } }, [tunnels]); // --- Load quality snapshots (auto-polling every 10s) --- const loadQuality = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; if (!silent) setQualityLoading(true); try { const response = await getMonitorTunnelQuality(); if (response.code === 0 && Array.isArray(response.data)) { const map: Record = {}; for (const q of response.data) { map[q.tunnelId] = q; } setQualityMap(map); // Accumulate locally setQualityHistoryMap((prev) => { const nextMap = { ...prev }; for (const q of response.data) { const currentArr = nextMap[q.tunnelId] || []; const lastItem = currentArr.length > 0 ? currentArr[currentArr.length - 1] : null; if (!lastItem || lastItem.timestamp !== q.timestamp) { nextMap[q.tunnelId] = [...currentArr, q].slice( -UPTIME_KUMA_BARS_COUNT, ); } } return nextMap; }); } } catch { // Silently ignore quality load failures } finally { if (!silent) setQualityLoading(false); } }, []); useEffect(() => { if (!monitorTunnelQualityEnabled) { return; } void loadQuality(); }, [loadQuality, monitorTunnelQualityEnabled]); useEffect(() => { if (!monitorTunnelQualityEnabled) { if (qualityTimerRef.current) { window.clearInterval(qualityTimerRef.current); qualityTimerRef.current = null; } return; } qualityTimerRef.current = window.setInterval(() => { void loadQuality({ silent: true }); }, QUALITY_POLL_INTERVAL); return () => { if (qualityTimerRef.current) { window.clearInterval(qualityTimerRef.current); qualityTimerRef.current = null; } }; }, [loadQuality, monitorTunnelQualityEnabled]); // --- Load quality history for detail chart --- const loadQualityHistory = useCallback( async (tunnelId: number, options?: { silent?: boolean }) => { const silent = options?.silent ?? false; if (!silent) setQualityHistoryLoading(true); try { const end = Date.now(); const start = end - qualityRangeMs; const response = await getMonitorTunnelQualityHistory( tunnelId, start, end, ); if (response.code === 0 && Array.isArray(response.data)) { setQualityHistoryError(null); setQualityHistory(response.data); return; } if (response.code === 403) { setAccessDenied(response.msg || "暂无监控权限"); return; } setQualityHistoryError(response.msg || "加载质量历史失败"); if (!silent) toast.error(response.msg || "加载质量历史失败"); } catch { if (!silent) { setQualityHistoryError("加载质量历史失败"); } } finally { if (!silent) setQualityHistoryLoading(false); } }, [qualityRangeMs], ); // --- Load tunnel traffic metrics for detail chart --- 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)) { setTunnelMetricsError(null); const ordered = [...response.data].sort( (a, b) => a.timestamp - b.timestamp, ); setTunnelMetrics(ordered); return; } setTunnelMetricsError(response.msg || "加载流量数据失败"); } catch { if (!silent) setTunnelMetricsError("加载流量数据失败"); } finally { if (!silent) setTunnelMetricsLoading(false); } }, [tunnelRangeMs], ); useEffect(() => { if (detailTunnelId) { void loadTunnelMetrics(detailTunnelId); if (monitorTunnelQualityEnabled) { void loadQualityHistory(detailTunnelId); } } }, [ detailTunnelId, loadQualityHistory, loadTunnelMetrics, monitorTunnelQualityEnabled, ]); // Auto-refresh detail charts useEffect(() => { if (!detailTunnelId) return; const timer = window.setInterval(() => { if (monitorTunnelQualityEnabled) { void loadQualityHistory(detailTunnelId, { silent: true }); } void loadTunnelMetrics(detailTunnelId, { silent: true }); }, 30_000); return () => window.clearInterval(timer); }, [ detailTunnelId, loadQualityHistory, loadTunnelMetrics, monitorTunnelQualityEnabled, ]); // Memoize chart data so React.memo sub-components see stable references const qualityChartData = useMemo( () => qualityHistory.map((q) => ({ time: formatTimestamp(q.timestamp, qualityRangeMs), entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null, exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null, entryToExitLoss: q.entryToExitLoss, exitToBingLoss: q.exitToBingLoss, })), [qualityHistory, qualityRangeMs], ); const tunnelChartData = useMemo( () => tunnelMetrics.map((m) => ({ time: formatTimestamp(m.timestamp, tunnelRangeMs), bytesIn: m.bytesIn, bytesOut: m.bytesOut, connections: m.connections, })), [tunnelMetrics, tunnelRangeMs], ); 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; return { total: tunnels.length, enabled }; }, [tunnels]); // Last quality update timestamp const lastQualityUpdate = useMemo(() => { let latest = 0; for (const q of Object.values(qualityMap)) { if (q.timestamp > latest) latest = q.timestamp; } return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null; }, [qualityMap]); if (accessDenied) { return (

监控权限

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

{detailTunnel.name}

{detailTunnel.status === 1 ? "启用" : "禁用"}
{/* Quality KPI Cards */}
入口 → 出口 延迟 出口 → 测试目标 延迟 入口 → 出口 丢包 0 ? "text-warning" : ""}`} > {quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"} 出口 → 测试目标 丢包 0 ? "text-warning" : ""}`} > {quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
{/* Auto-probe status */}
{monitorTunnelQualityEnabled ? ( <> 自动探测中(每秒测试,30秒上报) ) : ( <> 实时隧道质量检测已关闭 )} · 测试目标: {probeTargetLabel(quality)} {quality?.timestamp && ( · 最近更新:{" "} {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} )} {quality?.errorMessage && ( {quality.errorMessage} )}
{/* ====== Chain Topology ====== */} {monitorTunnelQualityEnabled && quality?.chainDetails && ( )} {/* ====== Quality History Chart — isolated with React.memo ====== */} {/* ====== Traffic Chart — isolated with React.memo ====== */}
); } // ===== LIST/GRID VIEW (unchanged) ===== return (
隧道 {tunnelStats.enabled}/{tunnelStats.total} {lastQualityUpdate ? (
{monitorTunnelQualityEnabled ? ( <> 每秒探测 · 更新于 {lastQualityUpdate} ) : ( <> 实时质量检测已关闭 · 最近更新于 {lastQualityUpdate} )}
) : !monitorTunnelQualityEnabled ? (
实时质量检测已关闭
) : null}
{tunnelsError ? (
{tunnelsError}
) : null} {viewMode === "grid" ? (
{tunnels.map((tunnel) => { const quality = qualityMap[tunnel.id]; const isEnabled = tunnel.status === 1; const targetLabel = probeTargetLabel(quality); return ( setDetailTunnelId(tunnel.id)} >

{tunnel.name}

{isEnabled ? "启用" : "禁用"}
入口→出口
出口→测试目标
{targetLabel}
{quality?.errorMessage ? ( {quality.errorMessage} ) : quality?.timestamp ? ( {monitorTunnelQualityEnabled ? ( ) : ( )} {new Date(quality.timestamp).toLocaleTimeString( "zh-CN", )} ) : ( {monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"} )}
); })}
) : ( 状态 名称 入口→出口 出口→测试目标 更新时间 {tunnels.map((tunnel) => { const quality = qualityMap[tunnel.id]; const isEnabled = tunnel.status === 1; const targetLabel = probeTargetLabel(quality); return ( setDetailTunnelId(tunnel.id)} >
{isEnabled ? ( ) : ( )}
{tunnel.name}
{targetLabel}
{quality?.timestamp ? ( {monitorTunnelQualityEnabled ? ( ) : ( )} {new Date(quality.timestamp).toLocaleTimeString( "zh-CN", )} ) : ( {monitorTunnelQualityEnabled ? "-" : "实时检测已关闭"} )}
); })}
)}
); }