import type { MonitorTunnelApiItem, TunnelMetricApiItem, TunnelQualityApiItem, } 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, } 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 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}`; 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`} label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }} /> { const n = Number(value); if (!Number.isFinite(n)) return "-"; const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name; return [`${n.toFixed(1)}ms`, label]; }} />
) : (
暂无质量历史数据
)}
); }); 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 ? (
) : (
暂无流量数据
)}
); }); 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>({}); const [qualityHistoryMap, setQualityHistoryMap] = useState>({}); 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 */}
入口 → 出口 延迟 出口 → Bing 延迟 入口 → 出口 丢包 0 ? "text-warning" : ""}`}> {quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"} 出口 → Bing 丢包 0 ? "text-warning" : ""}`}> {quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
{/* Auto-probe status */}
{monitorTunnelQualityEnabled ? ( <> 自动探测中(每秒测试,30秒上报) ) : ( <> 实时隧道质量检测已关闭 )} {quality?.timestamp && ( · 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} )} {quality?.errorMessage && ( {quality.errorMessage} )}
{/* ====== 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; return ( setDetailTunnelId(tunnel.id)} >

{tunnel.name}

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