import type { MonitorTunnelApiItem, TunnelMetricApiItem, TunnelQualityApiItem, } 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, } from "lucide-react"; import toast from "react-hot-toast"; import { getMonitorTunnels, getTunnelMetrics, getMonitorTunnelQuality, getMonitorTunnelQualityHistory, } 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 = 10_000; // 10 seconds 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", }); }; 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]}`; }; /** Render a colored latency value with appropriate visual cue */ 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; } /** Animated pulse dot for live status */ 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 (
); })}
); } 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); // 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); } }, []); useEffect(() => { void loadTunnels(); }, [loadTunnels]); useEffect(() => { const timer = window.setInterval(() => { void loadTunnels({ silent: true }); }, 60_000); return () => window.clearInterval(timer); }, [loadTunnels]); // --- Initial history load --- 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(() => { void loadQuality(); }, [loadQuality]); useEffect(() => { qualityTimerRef.current = window.setInterval(() => { void loadQuality({ silent: true }); }, QUALITY_POLL_INTERVAL); return () => { if (qualityTimerRef.current) { window.clearInterval(qualityTimerRef.current); } }; }, [loadQuality]); // --- 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 loadQualityHistory(detailTunnelId); void loadTunnelMetrics(detailTunnelId); } }, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]); // Auto-refresh detail charts useEffect(() => { if (!detailTunnelId) return; const timer = window.setInterval(() => { void loadQualityHistory(detailTunnelId, { silent: true }); void loadTunnelMetrics(detailTunnelId, { silent: true }); }, 30_000); return () => window.clearInterval(timer); }, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]); // Chart data for quality history const qualityChartData = 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, })); // Chart data for traffic metrics 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; 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]); /** Shared time range Select component */ const TimeRangeSelect = ({ value, onChange }: { value: number; onChange: (v: number) => void }) => ( ); // ===================== // RENDER // ===================== if (accessDenied) { return (

监控权限

{accessDenied}
如需使用监控功能,请联系管理员在用户页面授予监控权限。
); } // ===== DETAIL VIEW ===== 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 */}
自动探测中(每10秒) {quality?.timestamp && ( · 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} )} {quality?.errorMessage && ( {quality.errorMessage} )}
{/* ====== Quality History Chart (mirrors service monitor chart) ====== */}

质量趋势

{qualityHistoryLoading ? (
) : qualityHistoryError ? (
{qualityHistoryError}
) : qualityChartData.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]; }} />
) : (
暂无质量历史数据
)}
{/* ====== Traffic Chart (unchanged) ====== */}

流量趋势

{tunnelMetricsLoading ? (
) : tunnelMetricsError ? (
{tunnelMetricsError}
) : tunnelChartData.length > 0 ? (
) : (
暂无流量数据
)}
); } // ===== LIST/GRID VIEW (unchanged) ===== return (
隧道 {tunnelStats.enabled}/{tunnelStats.total} {lastQualityUpdate && (
自动探测 · 更新于 {lastQualityUpdate}
)}
{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 ? ( {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} ) : ( 等待探测... )}
); })}
) : ( 状态 名称 入口→出口 出口→Bing 更新时间 {tunnels.map((tunnel) => { const quality = qualityMap[tunnel.id]; const isEnabled = tunnel.status === 1; return ( setDetailTunnelId(tunnel.id)}>
{isEnabled ? ( ) : ( )}
{tunnel.name} {quality?.timestamp ? ( {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} ) : ( - )}
); })}
)}
); }