import type { MonitorTunnelApiItem, TunnelMetricApiItem, TunnelQualityApiItem, TunnelQualityCandidateHopApiItem, TunnelQualityChainDetailsApiItem, 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"; import { DEFAULT_TUNNEL_QUALITY_INTERVAL_SEC, parseTunnelQualityIntervalSeconds, TUNNEL_QUALITY_INTERVAL_CONFIG_KEY, tunnelQualityIntervalLabel, } from "@/config/tunnel-quality"; interface TunnelMonitorViewProps { viewMode?: "list" | "grid"; } 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 ? (
) : (
暂无流量数据
)}
); }); const parseTunnelQualityChainDetails = ( raw?: string, ): TunnelQualityChainDetailsApiItem => { if (!raw) return {}; try { const parsed: unknown = JSON.parse(raw); // Backward-compatible with historical rows that stored the primary path // directly as a JSON array. if (Array.isArray(parsed)) { return { primaryPath: parsed as TunnelQualityHopApiItem[] }; } if (parsed && typeof parsed === "object") { return parsed as TunnelQualityChainDetailsApiItem; } } catch { return {}; } return {}; }; 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, }: { hopsStr?: string; }) { const details = useMemo( () => parseTunnelQualityChainDetails(hopsStr), [hopsStr], ); const topologyPaths = useMemo( () => buildTunnelTopologyPaths(details), [details], ); if (topologyPaths.length === 0) return null; return (

全链路拓扑状态 (实时)

{topologyPaths.map((path, index) => (
))}
); }); 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); const [tunnelQualityIntervalSec, setTunnelQualityIntervalSec] = useState( DEFAULT_TUNNEL_QUALITY_INTERVAL_SEC, ); // 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 loadTunnelQualityConfig = useCallback(async () => { const [enabledResponse, intervalResponse] = await Promise.all([ getConfigByName(MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY).catch( () => null, ), getConfigByName(TUNNEL_QUALITY_INTERVAL_CONFIG_KEY).catch(() => null), ]); setMonitorTunnelQualityEnabled( typeof enabledResponse?.data?.value === "string" ? enabledResponse.data.value === "true" : true, ); setTunnelQualityIntervalSec( parseTunnelQualityIntervalSeconds(intervalResponse?.data?.value), ); }, []); useEffect(() => { void loadTunnels(); void loadTunnelQualityConfig(); }, [loadTunnelQualityConfig, loadTunnels]); useEffect(() => { const timer = window.setInterval(() => { void loadTunnels({ silent: true }); }, 60_000); return () => window.clearInterval(timer); }, [loadTunnels]); useEffect(() => { const handleMonitorTunnelQualityEnabledChanged = (event: Event) => { const detail = ( event as CustomEvent<{ enabled?: boolean; intervalSec?: number }> ).detail; const enabled = detail?.enabled; const intervalSec = detail?.intervalSec; if (typeof enabled === "boolean") { setMonitorTunnelQualityEnabled(enabled); if (!enabled) { setQualityLoading(false); } } else { void loadTunnelQualityConfig(); } if (typeof intervalSec === "number") { setTunnelQualityIntervalSec( parseTunnelQualityIntervalSeconds(String(intervalSec)), ); } }; window.addEventListener( MONITOR_TUNNEL_QUALITY_ENABLED_EVENT, handleMonitorTunnelQualityEnabledChanged as EventListener, ); return () => { window.removeEventListener( MONITOR_TUNNEL_QUALITY_ENABLED_EVENT, handleMonitorTunnelQualityEnabledChanged as EventListener, ); }; }, [loadTunnelQualityConfig]); 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 using the configured probe interval --- 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 }); }, tunnelQualityIntervalSec * 1000); return () => { if (qualityTimerRef.current) { window.clearInterval(qualityTimerRef.current); qualityTimerRef.current = null; } }; }, [loadQuality, monitorTunnelQualityEnabled, tunnelQualityIntervalSec]); // --- 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 ? ( <> {`自动探测中(${tunnelQualityIntervalLabel(tunnelQualityIntervalSec)}测试)`} ) : ( <> 实时隧道质量检测已关闭 )} · 测试目标: {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 ? ( <> {`${tunnelQualityIntervalLabel(tunnelQualityIntervalSec)}探测 · 更新于 ${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 ? "-" : "实时检测已关闭"} )}
); })}
)}
); }