From bc75ed745d8a6444d35c5ce4fc11272dbb7aeba3 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Thu, 19 Mar 2026 15:44:25 +0800 Subject: [PATCH] feat: monitor tunnel top level --- plans/050-monitor-tunnel-top-level.md | 40 + plans/051-sync-main-and-push-tag.md | 15 + vite-frontend/src/pages/monitor.tsx | 62 +- vite-frontend/src/pages/node/monitor-view.tsx | 439 ++++------ .../src/pages/node/tunnel-monitor-view.tsx | 829 ++++++++++++++++++ 5 files changed, 1083 insertions(+), 302 deletions(-) create mode 100644 plans/050-monitor-tunnel-top-level.md create mode 100644 plans/051-sync-main-and-push-tag.md create mode 100644 vite-frontend/src/pages/node/tunnel-monitor-view.tsx diff --git a/plans/050-monitor-tunnel-top-level.md b/plans/050-monitor-tunnel-top-level.md new file mode 100644 index 0000000..604d055 --- /dev/null +++ b/plans/050-monitor-tunnel-top-level.md @@ -0,0 +1,40 @@ +# 050 - 监控页面隧道独立入口 + +## 背景 +当前监控页面的隧道流量监控嵌套在节点详情内,用户需要先点击某个节点才能看到隧道流量。 +需要将隧道监控提升为与节点列表同级的入口,展示隧道质量(延迟)和流量统计。 + +## 设计 + +### 监控页面结构变更 +在 `monitor.tsx` 页面增加 Tab 式结构(或并列区域),新增"隧道监控"入口与节点列表同级: +- **节点** Tab:保持现有节点卡片/列表 + 详情视图 +- **隧道** Tab:新增隧道卡片/列表视图 + - 每个隧道卡片显示:隧道名称、状态、质量指标(入口→出口延迟、出口→Bing TCP延迟) + - 点击隧道进入详情:隧道流量趋势图表(已有的 tunnel metrics chart) + +### 隧道质量指标 +使用已有的隧道诊断 API (`/tunnel/diagnose`) 进行 TCP 延迟测试: +- 入口→出口延迟 +- 出口→Bing TCP 延迟(bing.com:443) + +新增一个轻量级的后端 API 来获取隧道质量快照(定时 TCP 测试延迟), +或者前端在隧道 Tab 里手动触发诊断。 + +### 实现方案(前端为主) +由于后端已有完整的 tunnel metrics 和 diagnosis API,本次改动主要在前端: + +1. `monitor.tsx` 增加 Tab 切换(节点 / 隧道) +2. 新建 `tunnel-monitor-view.tsx` 组件 +3. 隧道列表用卡片展示质量指标 +4. 点击进入隧道详情,展示流量图表 +5. 从 `monitor-view.tsx` 的节点详情中移除隧道流量图表 + +## 任务清单 + +- [x] 1. 修改 `monitor.tsx`,增加节点/隧道 Tab 切换 +- [x] 2. 新建 `tunnel-monitor-view.tsx` 组件,实现隧道列表视图 +- [x] 3. 隧道卡片展示名称、状态、质量指标(支持手动诊断获取延迟) +- [x] 4. 隧道详情视图,展示流量趋势图表(复用已有 tunnel metrics API) +- [x] 5. 从节点详情视图中移除隧道流量图表 +- [x] 6. TypeScript 编译通过验证 diff --git a/plans/051-sync-main-and-push-tag.md b/plans/051-sync-main-and-push-tag.md new file mode 100644 index 0000000..57c591e --- /dev/null +++ b/plans/051-sync-main-and-push-tag.md @@ -0,0 +1,15 @@ +# Plan 051: Sync Main and Push Tag + +## Goal +Submit all changes, push them to the remote repository, create a pull request to main, merge it, and push a new tag. + +## Tasks +- [ ] Determine the next tag version. +- [ ] Add all changes to the staging area. +- [ ] Commit the changes. +- [ ] Push changes to the current branch. +- [ ] Create a pull request via `gh` CLI. +- [ ] Merge the pull request via `gh` CLI. +- [ ] Sync the `main` branch locally. +- [ ] Create a new tag. +- [ ] Push the new tag to the remote repository. diff --git a/vite-frontend/src/pages/monitor.tsx b/vite-frontend/src/pages/monitor.tsx index 093e98e..91add49 100644 --- a/vite-frontend/src/pages/monitor.tsx +++ b/vite-frontend/src/pages/monitor.tsx @@ -2,13 +2,14 @@ import type { MonitorNodeApiItem } from "@/api/types"; import { useCallback, useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; -import { RefreshCw, LayoutGrid, List } from "lucide-react"; +import { RefreshCw, LayoutGrid, List, Server, ArrowRightLeft } from "lucide-react"; import { AnimatedPage } from "@/components/animated-page"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { getMonitorNodes } from "@/api"; import { MonitorView } from "@/pages/node/monitor-view"; +import { TunnelMonitorView } from "@/pages/node/tunnel-monitor-view"; type MonitorNode = { id: number; @@ -16,11 +17,14 @@ type MonitorNode = { connectionStatus: "online" | "offline"; }; +type MonitorTab = "nodes" | "tunnels"; + export default function MonitorPage() { const [nodes, setNodes] = useState([]); const [nodesLoading, setNodesLoading] = useState(false); const [nodesError, setNodesError] = useState(null); const [viewMode, setViewMode] = useState<"list" | "grid">("list"); + const [activeTab, setActiveTab] = useState("nodes"); const loadNodes = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; @@ -81,7 +85,7 @@ export default function MonitorPage() {

监控

- 实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP) + 实时节点状态 + 隧道质量检测 + 历史指标图表 + 服务监控(TCP/ICMP)
@@ -93,19 +97,47 @@ export default function MonitorPage() { > {viewMode === "list" ? : } - + {activeTab === "nodes" && ( + + )}
- {nodesError ? ( + {/* Tab Switcher */} +
+ + +
+ + {nodesError && activeTab === "nodes" ? (

节点列表

@@ -117,7 +149,11 @@ export default function MonitorPage() { ) : null} - + {activeTab === "nodes" ? ( + + ) : ( + + )} ); } diff --git a/vite-frontend/src/pages/node/monitor-view.tsx b/vite-frontend/src/pages/node/monitor-view.tsx index e748ff3..af3e288 100644 --- a/vite-frontend/src/pages/node/monitor-view.tsx +++ b/vite-frontend/src/pages/node/monitor-view.tsx @@ -1,7 +1,5 @@ import type { - MonitorTunnelApiItem, NodeMetricApiItem, - TunnelMetricApiItem, ServiceMonitorApiItem, ServiceMonitorResultApiItem, ServiceMonitorMutationPayload, @@ -34,9 +32,7 @@ import { import toast from "react-hot-toast"; import { - getMonitorTunnels, getNodeMetrics, - getTunnelMetrics, getServiceMonitorList, getServiceMonitorLimits, getServiceMonitorResults, @@ -287,17 +283,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { const [metricsRangeMs, setMetricsRangeMs] = useState(60 * 60 * 1000); - const [tunnels, setTunnels] = useState([]); - const [tunnelsLoading, setTunnelsLoading] = useState(false); - const [, setTunnelsError] = useState(null); - const [selectedTunnelId, setSelectedTunnelId] = useState(null); - const [tunnelMetrics, setTunnelMetrics] = useState([]); - const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false); - const [, setTunnelMetricsTruncated] = useState(false); - const [tunnelMetricsError, setTunnelMetricsError] = useState( - null, - ); - const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000); + const [serviceMonitors, setServiceMonitors] = useState< ServiceMonitorApiItem[] @@ -327,6 +313,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { enabled: true, }); const [submitLoading, setSubmitLoading] = useState(false); + const [activeServiceMonitorId, setActiveServiceMonitorId] = useState(null); + const [serviceMonitorRangeLimit, setServiceMonitorRangeLimit] = useState(50); const [accessDenied, setAccessDenied] = useState(null); const [resultsModalOpen, setResultsModalOpen] = useState(false); @@ -446,111 +434,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { } }, [preferredNodeId, selectedNodeId]); - 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]); - - useEffect(() => { - if (!selectedTunnelId && tunnels.length > 0) { - setSelectedTunnelId(tunnels[0].id); - } - }, [selectedTunnelId, tunnels]); - - 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 (selectedTunnelId) { - void loadTunnelMetrics(selectedTunnelId); - } - }, [selectedTunnelId, loadTunnelMetrics]); - - useEffect(() => { - if (!selectedTunnelId) return; - const timer = window.setInterval(() => { - void loadTunnelMetrics(selectedTunnelId, { silent: true }); - }, 30_000); - - return () => window.clearInterval(timer); - }, [selectedTunnelId, loadTunnelMetrics]); const loadMetrics = useCallback( async (nodeId: number, options?: { silent?: boolean }) => { @@ -757,6 +641,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { void loadResultsForModal(); }, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]); + // Reload results for the active service monitor chart when range limit changes + useEffect(() => { + if (!activeServiceMonitorId) return; + void loadMonitorResults(activeServiceMonitorId, serviceMonitorRangeLimit); + }, [activeServiceMonitorId, serviceMonitorRangeLimit, loadMonitorResults]); + const chartData = metrics.map((m) => ({ time: formatTimestamp(m.timestamp, metricsRangeMs), cpu: m.cpuUsage, @@ -816,21 +706,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { } }; - const tunnelChartData = tunnelMetrics.map((m) => ({ - time: formatTimestamp(m.timestamp, tunnelRangeMs), - bytesIn: m.bytesIn, - bytesOut: m.bytesOut, - })); - const getTunnelChartConfig = () => { - return { - lines: [ - { dataKey: "bytesIn", color: "#10b981", name: "入站流量" }, - { dataKey: "bytesOut", color: "#ef4444", name: "出站流量" }, - ], - yAxisLabel: "流量", - }; - }; const handleOpenEditModal = (monitor?: ServiceMonitorApiItem) => { if (monitor) { @@ -1091,7 +967,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { }, [getLatestResult, isResultStale, serviceMonitors]); const chartConfig = getChartConfig(); - const tunnelChartConfig = getTunnelChartConfig(); const nodeYAxisTickFormatter = (value: unknown) => { const n = Number(value); @@ -1131,21 +1006,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { } }; - 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 detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null; const detailRealtimeMetric = detailNodeId != null ? realtimeNodeMetrics[detailNodeId] || null : null; @@ -1440,158 +1301,158 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
- {/* Tunnel traffic chart */} - - -

隧道流量趋势

-
- - -
-
- - {selectedTunnelId ? ( - tunnelMetricsLoading ? ( -
- ) : tunnelMetricsError ? ( -
{tunnelMetricsError}
- ) : tunnelMetrics.length > 0 ? ( -
- - - - - - - {tunnelChartConfig.lines.map((line) => ( - - ))} - - + + {/* Service monitors chart – same style as node metrics */} + {(() => { + // Resolve which monitor is active + const resolvedActiveMonitor = + detailServiceMonitors.find((m) => m.id === activeServiceMonitorId) || + detailServiceMonitors[0] || + null; + const resolvedActiveMonitorId = resolvedActiveMonitor?.id ?? null; + const activeLatestResult = resolvedActiveMonitorId != null ? getLatestResult(resolvedActiveMonitorId) : null; + const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false; + const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : []; + const activeLatencyData = [...activeResults] + .slice(0, serviceMonitorRangeLimit) + .reverse() + .map((r) => ({ + time: formatTimestamp(r.timestamp), + latency: r.success === 1 ? r.latencyMs : null, + success: r.success, + })); + + return ( + + +

服务监控图表

+
+ + {resolvedActiveMonitorId != null && ( + + )} +
- ) : ( -
暂无指标数据
- ) - ) : ( -
请选择一个隧道查看指标
- )} - -
+ + + {monitorsLoading ? ( +
+ ) : detailServiceMonitors.length > 0 ? ( + <> + {/* Monitor switcher buttons – like metric type tabs */} +
+ {detailServiceMonitors.map((monitor) => { + const lr = getLatestResult(monitor.id); + const statusColor = + monitor.enabled !== 1 ? "default" : + !lr ? "default" : + lr.success === 1 ? "success" : "danger"; + const isActive = monitor.id === resolvedActiveMonitorId; - {/* Service monitors with latency charts */} - - -

服务监控

- -
- - {monitorsLoading ? ( -
- ) : detailServiceMonitors.length > 0 ? ( -
- {detailServiceMonitors.map((monitor) => { - const latestResult = getLatestResult(monitor.id); - const stale = isResultStale(monitor, latestResult); - const results = monitorResults[monitor.id] || []; - const latencyData = [...results].reverse().map((r) => ({ - time: formatTimestamp(r.timestamp), - latency: r.success === 1 ? r.latencyMs : null, - success: r.success, - })); + return ( + + ); + })} +
- return ( - - -
-
- {monitor.name} - {monitor.type.toUpperCase()} - {stale ? 陈旧 : null} + {/* Active monitor info bar */} + {resolvedActiveMonitor && ( +
+
+ {resolvedActiveMonitor.type.toUpperCase()} + {resolvedActiveMonitor.target} + 间隔 {resolvedActiveMonitor.intervalSec}s + {activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? ( + {activeLatestResult.latencyMs.toFixed(0)}ms + ) : null} + {activeStale ? 陈旧 : null} + {resolvedActiveMonitor.enabled !== 1 ? 已禁用 : null}
- } onPress={() => handleRunMonitor(monitor.id)}>立即检查 - } onPress={() => openResultsModal(monitor.id)}>查看记录 - } onPress={() => handleOpenEditModal(monitor)}>编辑 - } onPress={() => handleDeleteMonitor(monitor.id)}>删除 + } onPress={() => resolvedActiveMonitorId != null && handleRunMonitor(resolvedActiveMonitorId)}>立即检查 + } onPress={() => resolvedActiveMonitorId != null && openResultsModal(resolvedActiveMonitorId)}>查看记录 + } onPress={() => resolvedActiveMonitor && handleOpenEditModal(resolvedActiveMonitor)}>编辑 + } onPress={() => resolvedActiveMonitorId != null && handleDeleteMonitor(resolvedActiveMonitorId)}>删除 - - -
- {monitor.target} - 间隔 {monitor.intervalSec}s - {latestResult && Number.isFinite(latestResult.latencyMs) ? ( - {latestResult.latencyMs.toFixed(0)}ms - ) : null} -
- {latencyData.length > 0 ? ( -
- - - - - `${Math.round(v)}ms`} /> - [`${Number(value).toFixed(0)}ms`, "延迟"]} - /> - - - -
- ) : ( -
暂无检查记录
- )} -
- - ); - })} -
- ) : ( -
暂无服务监控,点击"添加监控"创建
- )} - - +
+ )} + + {/* Chart area – same h-64 as node metrics */} + {activeLatencyData.length > 0 ? ( +
+ + + + + `${Math.round(v)}ms`} /> + [`${Number(value).toFixed(0)}ms`, "延迟"]} + /> + + + +
+ ) : ( +
暂无检查记录
+ )} + + ) : ( +
暂无服务监控,点击"添加监控"创建
+ )} + + + ); + })()} )} diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx new file mode 100644 index 0000000..93135cb --- /dev/null +++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx @@ -0,0 +1,829 @@ +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} + + ) : ( + - + )} + + +
+ +
+
+
+ ); + })} +
+
+
+ )} +
+ ); +}