mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
@@ -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 编译通过验证
|
||||||
@@ -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.
|
||||||
@@ -2,13 +2,14 @@ import type { MonitorNodeApiItem } from "@/api/types";
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
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 { AnimatedPage } from "@/components/animated-page";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
import { getMonitorNodes } from "@/api";
|
import { getMonitorNodes } from "@/api";
|
||||||
import { MonitorView } from "@/pages/node/monitor-view";
|
import { MonitorView } from "@/pages/node/monitor-view";
|
||||||
|
import { TunnelMonitorView } from "@/pages/node/tunnel-monitor-view";
|
||||||
|
|
||||||
type MonitorNode = {
|
type MonitorNode = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,11 +17,14 @@ type MonitorNode = {
|
|||||||
connectionStatus: "online" | "offline";
|
connectionStatus: "online" | "offline";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MonitorTab = "nodes" | "tunnels";
|
||||||
|
|
||||||
export default function MonitorPage() {
|
export default function MonitorPage() {
|
||||||
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
|
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
|
||||||
const [nodesLoading, setNodesLoading] = useState(false);
|
const [nodesLoading, setNodesLoading] = useState(false);
|
||||||
const [nodesError, setNodesError] = useState<string | null>(null);
|
const [nodesError, setNodesError] = useState<string | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||||
|
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
|
||||||
|
|
||||||
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
||||||
const silent = options?.silent ?? false;
|
const silent = options?.silent ?? false;
|
||||||
@@ -81,7 +85,7 @@ export default function MonitorPage() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-xl font-semibold truncate">监控</h2>
|
<h2 className="text-xl font-semibold truncate">监控</h2>
|
||||||
<div className="text-xs text-default-500 truncate">
|
<div className="text-xs text-default-500 truncate">
|
||||||
实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP)
|
实时节点状态 + 隧道质量检测 + 历史指标图表 + 服务监控(TCP/ICMP)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -93,19 +97,47 @@ export default function MonitorPage() {
|
|||||||
>
|
>
|
||||||
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
|
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{activeTab === "nodes" && (
|
||||||
isLoading={nodesLoading}
|
<Button
|
||||||
size="sm"
|
isLoading={nodesLoading}
|
||||||
variant="flat"
|
size="sm"
|
||||||
onPress={() => loadNodes()}
|
variant="flat"
|
||||||
>
|
onPress={() => loadNodes()}
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
>
|
||||||
刷新节点
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
</Button>
|
刷新节点
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{nodesError ? (
|
{/* Tab Switcher */}
|
||||||
|
<div className="flex items-center gap-1 p-1 rounded-xl bg-default-100 dark:bg-default-50/10 w-fit">
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||||
|
activeTab === "nodes"
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-default-500 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab("nodes")}
|
||||||
|
>
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
节点
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||||
|
activeTab === "tunnels"
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-default-500 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab("tunnels")}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="w-4 h-4" />
|
||||||
|
隧道
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodesError && activeTab === "nodes" ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="text-sm font-semibold">节点列表</h3>
|
<h3 className="text-sm font-semibold">节点列表</h3>
|
||||||
@@ -117,7 +149,11 @@ export default function MonitorPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
|
{activeTab === "nodes" ? (
|
||||||
|
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
|
||||||
|
) : (
|
||||||
|
<TunnelMonitorView viewMode={viewMode} />
|
||||||
|
)}
|
||||||
</AnimatedPage>
|
</AnimatedPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
MonitorTunnelApiItem,
|
|
||||||
NodeMetricApiItem,
|
NodeMetricApiItem,
|
||||||
TunnelMetricApiItem,
|
|
||||||
ServiceMonitorApiItem,
|
ServiceMonitorApiItem,
|
||||||
ServiceMonitorResultApiItem,
|
ServiceMonitorResultApiItem,
|
||||||
ServiceMonitorMutationPayload,
|
ServiceMonitorMutationPayload,
|
||||||
@@ -34,9 +32,7 @@ import {
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getMonitorTunnels,
|
|
||||||
getNodeMetrics,
|
getNodeMetrics,
|
||||||
getTunnelMetrics,
|
|
||||||
getServiceMonitorList,
|
getServiceMonitorList,
|
||||||
getServiceMonitorLimits,
|
getServiceMonitorLimits,
|
||||||
getServiceMonitorResults,
|
getServiceMonitorResults,
|
||||||
@@ -287,17 +283,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
|
|
||||||
const [metricsRangeMs, setMetricsRangeMs] = useState(60 * 60 * 1000);
|
const [metricsRangeMs, setMetricsRangeMs] = useState(60 * 60 * 1000);
|
||||||
|
|
||||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
|
||||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
|
||||||
const [, setTunnelsError] = useState<string | null>(null);
|
|
||||||
const [selectedTunnelId, setSelectedTunnelId] = useState<number | null>(null);
|
|
||||||
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
|
|
||||||
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
|
|
||||||
const [, setTunnelMetricsTruncated] = useState(false);
|
|
||||||
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
|
|
||||||
|
|
||||||
const [serviceMonitors, setServiceMonitors] = useState<
|
const [serviceMonitors, setServiceMonitors] = useState<
|
||||||
ServiceMonitorApiItem[]
|
ServiceMonitorApiItem[]
|
||||||
@@ -327,6 +313,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
|
const [activeServiceMonitorId, setActiveServiceMonitorId] = useState<number | null>(null);
|
||||||
|
const [serviceMonitorRangeLimit, setServiceMonitorRangeLimit] = useState(50);
|
||||||
|
|
||||||
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
||||||
const [resultsModalOpen, setResultsModalOpen] = useState(false);
|
const [resultsModalOpen, setResultsModalOpen] = useState(false);
|
||||||
@@ -446,111 +434,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
}
|
}
|
||||||
}, [preferredNodeId, selectedNodeId]);
|
}, [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(
|
const loadMetrics = useCallback(
|
||||||
async (nodeId: number, options?: { silent?: boolean }) => {
|
async (nodeId: number, options?: { silent?: boolean }) => {
|
||||||
@@ -757,6 +641,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
void loadResultsForModal();
|
void loadResultsForModal();
|
||||||
}, [resultsModalOpen, resultsMonitorId, resultsLimit, 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) => ({
|
const chartData = metrics.map((m) => ({
|
||||||
time: formatTimestamp(m.timestamp, metricsRangeMs),
|
time: formatTimestamp(m.timestamp, metricsRangeMs),
|
||||||
cpu: m.cpuUsage,
|
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) => {
|
const handleOpenEditModal = (monitor?: ServiceMonitorApiItem) => {
|
||||||
if (monitor) {
|
if (monitor) {
|
||||||
@@ -1091,7 +967,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
}, [getLatestResult, isResultStale, serviceMonitors]);
|
}, [getLatestResult, isResultStale, serviceMonitors]);
|
||||||
|
|
||||||
const chartConfig = getChartConfig();
|
const chartConfig = getChartConfig();
|
||||||
const tunnelChartConfig = getTunnelChartConfig();
|
|
||||||
|
|
||||||
const nodeYAxisTickFormatter = (value: unknown) => {
|
const nodeYAxisTickFormatter = (value: unknown) => {
|
||||||
const n = Number(value);
|
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 detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null;
|
||||||
const detailRealtimeMetric = detailNodeId != null ? realtimeNodeMetrics[detailNodeId] || null : null;
|
const detailRealtimeMetric = detailNodeId != null ? realtimeNodeMetrics[detailNodeId] || null : null;
|
||||||
@@ -1440,158 +1301,158 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tunnel traffic chart */}
|
|
||||||
<Card>
|
{/* Service monitors chart – same style as node metrics */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
{(() => {
|
||||||
<h3 className="text-lg font-semibold">隧道流量趋势</h3>
|
// Resolve which monitor is active
|
||||||
<div className="flex items-center gap-2">
|
const resolvedActiveMonitor =
|
||||||
<Select
|
detailServiceMonitors.find((m) => m.id === activeServiceMonitorId) ||
|
||||||
className="w-56"
|
detailServiceMonitors[0] ||
|
||||||
placeholder={tunnelsLoading ? "加载中..." : "选择隧道"}
|
null;
|
||||||
selectedKeys={selectedTunnelId ? [String(selectedTunnelId)] : []}
|
const resolvedActiveMonitorId = resolvedActiveMonitor?.id ?? null;
|
||||||
onSelectionChange={(keys) => {
|
const activeLatestResult = resolvedActiveMonitorId != null ? getLatestResult(resolvedActiveMonitorId) : null;
|
||||||
const tunnelId = Number(Array.from(keys)[0]);
|
const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false;
|
||||||
setSelectedTunnelId(tunnelId || null);
|
const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : [];
|
||||||
}}
|
const activeLatencyData = [...activeResults]
|
||||||
>
|
.slice(0, serviceMonitorRangeLimit)
|
||||||
{tunnels.map((t) => (
|
.reverse()
|
||||||
<SelectItem key={String(t.id)}>{t.name}{t.status === 1 ? "" : " (禁用)"}</SelectItem>
|
.map((r) => ({
|
||||||
))}
|
time: formatTimestamp(r.timestamp),
|
||||||
</Select>
|
latency: r.success === 1 ? r.latencyMs : null,
|
||||||
<Select
|
success: r.success,
|
||||||
className="w-36"
|
}));
|
||||||
selectedKeys={[String(tunnelRangeMs)]}
|
|
||||||
onSelectionChange={(keys) => {
|
return (
|
||||||
const v = Number(Array.from(keys)[0]);
|
<Card>
|
||||||
if (v > 0) setTunnelRangeMs(v);
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
}}
|
<h3 className="text-lg font-semibold">服务监控图表</h3>
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
|
<Select
|
||||||
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
|
className="w-28"
|
||||||
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
|
selectedKeys={[String(serviceMonitorRangeLimit)]}
|
||||||
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
|
onSelectionChange={(keys) => {
|
||||||
</Select>
|
const v = Number(Array.from(keys)[0]);
|
||||||
</div>
|
if (v > 0) setServiceMonitorRangeLimit(v);
|
||||||
</CardHeader>
|
}}
|
||||||
<CardBody>
|
>
|
||||||
{selectedTunnelId ? (
|
<SelectItem key="20">20条</SelectItem>
|
||||||
tunnelMetricsLoading ? (
|
<SelectItem key="50">50条</SelectItem>
|
||||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
<SelectItem key="100">100条</SelectItem>
|
||||||
) : tunnelMetricsError ? (
|
<SelectItem key="200">200条</SelectItem>
|
||||||
<div className="text-center py-8 text-danger text-sm">{tunnelMetricsError}</div>
|
</Select>
|
||||||
) : tunnelMetrics.length > 0 ? (
|
{resolvedActiveMonitorId != null && (
|
||||||
<div className="h-64">
|
<Button
|
||||||
<ResponsiveContainer height="100%" width="100%">
|
size="sm"
|
||||||
<LineChart data={tunnelChartData}>
|
variant="flat"
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
onPress={() => {
|
||||||
<XAxis dataKey="time" fontSize={12} />
|
void loadMonitorResults(resolvedActiveMonitorId, serviceMonitorRangeLimit);
|
||||||
<YAxis fontSize={12} tickFormatter={tunnelYAxisTickFormatter} />
|
}}
|
||||||
<Tooltip
|
>
|
||||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
labelStyle={{ color: "#fff" }}
|
刷新
|
||||||
formatter={tunnelTooltipFormatter}
|
</Button>
|
||||||
/>
|
)}
|
||||||
{tunnelChartConfig.lines.map((line) => (
|
<Button color="primary" size="sm" variant="flat" onPress={() => handleOpenEditModal()}>
|
||||||
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
))}
|
添加监控
|
||||||
</LineChart>
|
</Button>
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</CardHeader>
|
||||||
<div className="text-center py-8 text-default-500">暂无指标数据</div>
|
<CardBody className="space-y-4">
|
||||||
)
|
{monitorsLoading ? (
|
||||||
) : (
|
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
||||||
<div className="text-center py-8 text-default-500">请选择一个隧道查看指标</div>
|
) : detailServiceMonitors.length > 0 ? (
|
||||||
)}
|
<>
|
||||||
</CardBody>
|
{/* Monitor switcher buttons – like metric type tabs */}
|
||||||
</Card>
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{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 */}
|
return (
|
||||||
<Card>
|
<Button
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
key={monitor.id}
|
||||||
<h3 className="text-lg font-semibold">服务监控</h3>
|
color={isActive ? "primary" : statusColor as any}
|
||||||
<Button color="primary" size="sm" variant="flat" onPress={() => handleOpenEditModal()}>
|
size="sm"
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
variant={isActive ? "solid" : "flat"}
|
||||||
添加监控
|
onPress={() => {
|
||||||
</Button>
|
setActiveServiceMonitorId(monitor.id);
|
||||||
</CardHeader>
|
// Load results for this monitor if not loaded
|
||||||
<CardBody className="space-y-4">
|
if (!monitorResults[monitor.id] || monitorResults[monitor.id].length <= 1) {
|
||||||
{monitorsLoading ? (
|
void loadMonitorResults(monitor.id, serviceMonitorRangeLimit);
|
||||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
}
|
||||||
) : detailServiceMonitors.length > 0 ? (
|
}}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
>
|
||||||
{detailServiceMonitors.map((monitor) => {
|
<div className={`w-2 h-2 rounded-full shrink-0 mr-1 ${
|
||||||
const latestResult = getLatestResult(monitor.id);
|
monitor.enabled !== 1 ? "bg-default-300" :
|
||||||
const stale = isResultStale(monitor, latestResult);
|
!lr ? "bg-default-400" :
|
||||||
const results = monitorResults[monitor.id] || [];
|
lr.success === 1 ? (isActive ? "bg-white" : "bg-success") : (isActive ? "bg-white" : "bg-danger")
|
||||||
const latencyData = [...results].reverse().map((r) => ({
|
}`} />
|
||||||
time: formatTimestamp(r.timestamp),
|
{monitor.name}
|
||||||
latency: r.success === 1 ? r.latencyMs : null,
|
</Button>
|
||||||
success: r.success,
|
);
|
||||||
}));
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{/* Active monitor info bar */}
|
||||||
<Card key={monitor.id} className="border border-default-200/60">
|
{resolvedActiveMonitor && (
|
||||||
<CardHeader className="pb-1 flex flex-row items-center justify-between">
|
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-default-50 dark:bg-default-100/30 border border-divider">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-3 min-w-0 flex-wrap">
|
||||||
<div className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
<Chip size="sm" color="primary" variant="flat">{resolvedActiveMonitor.type.toUpperCase()}</Chip>
|
||||||
monitor.enabled !== 1 ? "bg-default-300" :
|
<span className="font-mono text-xs text-default-500">{resolvedActiveMonitor.target}</span>
|
||||||
!latestResult ? "bg-default-400" :
|
<span className="text-xs text-default-500">间隔 {resolvedActiveMonitor.intervalSec}s</span>
|
||||||
latestResult.success === 1 ? "bg-success" : "bg-danger"
|
{activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? (
|
||||||
}`} />
|
<span className="font-mono text-xs font-semibold text-success">{activeLatestResult.latencyMs.toFixed(0)}ms</span>
|
||||||
<span className="font-semibold text-sm truncate">{monitor.name}</span>
|
) : null}
|
||||||
<Chip size="sm" color="primary" variant="flat">{monitor.type.toUpperCase()}</Chip>
|
{activeStale ? <Chip size="sm" color="warning" variant="flat">陈旧</Chip> : null}
|
||||||
{stale ? <Chip size="sm" color="warning" variant="flat">陈旧</Chip> : null}
|
{resolvedActiveMonitor.enabled !== 1 ? <Chip size="sm" color="default" variant="flat">已禁用</Chip> : null}
|
||||||
</div>
|
</div>
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownTrigger>
|
<DropdownTrigger>
|
||||||
<Button isIconOnly size="sm" variant="light"><MoreVertical className="w-4 h-4" /></Button>
|
<Button isIconOnly size="sm" variant="light"><MoreVertical className="w-4 h-4" /></Button>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownItem startContent={<Play className="w-4 h-4" />} onPress={() => handleRunMonitor(monitor.id)}>立即检查</DropdownItem>
|
<DropdownItem startContent={<Play className="w-4 h-4" />} onPress={() => resolvedActiveMonitorId != null && handleRunMonitor(resolvedActiveMonitorId)}>立即检查</DropdownItem>
|
||||||
<DropdownItem startContent={<Activity className="w-4 h-4" />} onPress={() => openResultsModal(monitor.id)}>查看记录</DropdownItem>
|
<DropdownItem startContent={<Activity className="w-4 h-4" />} onPress={() => resolvedActiveMonitorId != null && openResultsModal(resolvedActiveMonitorId)}>查看记录</DropdownItem>
|
||||||
<DropdownItem startContent={<Edit className="w-4 h-4" />} onPress={() => handleOpenEditModal(monitor)}>编辑</DropdownItem>
|
<DropdownItem startContent={<Edit className="w-4 h-4" />} onPress={() => resolvedActiveMonitor && handleOpenEditModal(resolvedActiveMonitor)}>编辑</DropdownItem>
|
||||||
<DropdownItem className="text-danger" color="danger" startContent={<Trash2 className="w-4 h-4" />} onPress={() => handleDeleteMonitor(monitor.id)}>删除</DropdownItem>
|
<DropdownItem className="text-danger" color="danger" startContent={<Trash2 className="w-4 h-4" />} onPress={() => resolvedActiveMonitorId != null && handleDeleteMonitor(resolvedActiveMonitorId)}>删除</DropdownItem>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardBody className="pt-0 space-y-2">
|
)}
|
||||||
<div className="flex items-center gap-3 text-xs text-default-500">
|
|
||||||
<span className="font-mono">{monitor.target}</span>
|
{/* Chart area – same h-64 as node metrics */}
|
||||||
<span>间隔 {monitor.intervalSec}s</span>
|
{activeLatencyData.length > 0 ? (
|
||||||
{latestResult && Number.isFinite(latestResult.latencyMs) ? (
|
<div className="h-64">
|
||||||
<span className="font-mono text-success">{latestResult.latencyMs.toFixed(0)}ms</span>
|
<ResponsiveContainer height="100%" width="100%">
|
||||||
) : null}
|
<LineChart data={activeLatencyData}>
|
||||||
</div>
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
{latencyData.length > 0 ? (
|
<XAxis dataKey="time" fontSize={12} />
|
||||||
<div className="h-24">
|
<YAxis fontSize={12} tickFormatter={(v: number) => `${Math.round(v)}ms`} />
|
||||||
<ResponsiveContainer height="100%" width="100%">
|
<Tooltip
|
||||||
<LineChart data={latencyData}>
|
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(128,128,128,0.15)" />
|
labelStyle={{ color: "#fff" }}
|
||||||
<XAxis dataKey="time" fontSize={10} tick={false} />
|
formatter={(value: unknown) => [`${Number(value).toFixed(0)}ms`, "延迟"]}
|
||||||
<YAxis fontSize={10} tickFormatter={(v: number) => `${Math.round(v)}ms`} />
|
/>
|
||||||
<Tooltip
|
<Line dataKey="latency" dot={false} stroke="#10b981" strokeWidth={2} type="monotone" connectNulls={false} name="延迟" />
|
||||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: "12px" }}
|
</LineChart>
|
||||||
labelStyle={{ color: "#fff" }}
|
</ResponsiveContainer>
|
||||||
formatter={(value: unknown) => [`${Number(value).toFixed(0)}ms`, "延迟"]}
|
</div>
|
||||||
/>
|
) : (
|
||||||
<Line dataKey="latency" dot={false} stroke="#10b981" strokeWidth={1.5} type="monotone" connectNulls={false} />
|
<div className="text-center py-8 text-default-500">暂无检查记录</div>
|
||||||
</LineChart>
|
)}
|
||||||
</ResponsiveContainer>
|
</>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="text-center py-8 text-default-500">暂无服务监控,点击"添加监控"创建</div>
|
||||||
<div className="text-xs text-default-400 py-2">暂无检查记录</div>
|
)}
|
||||||
)}
|
</CardBody>
|
||||||
</CardBody>
|
</Card>
|
||||||
</Card>
|
);
|
||||||
);
|
})()}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-default-500">暂无服务监控,点击"添加监控"创建</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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<MonitorTunnelApiItem[]>([]);
|
||||||
|
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||||
|
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
|
||||||
|
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Detail view state
|
||||||
|
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
|
||||||
|
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
|
||||||
|
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
|
||||||
|
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
|
||||||
|
const [, setTunnelMetricsTruncated] = useState(false);
|
||||||
|
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Tunnel quality (diagnosis) state
|
||||||
|
const [tunnelQualities, setTunnelQualities] = useState<Record<number, TunnelQuality>>({});
|
||||||
|
const diagnosisAbortRef = useRef<Record<number, AbortController>>({});
|
||||||
|
|
||||||
|
// 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<TunnelQuality, "entryToExitLatency" | "exitToBingLatency" | "entryToExitLoss" | "exitToBingLoss"> => {
|
||||||
|
// 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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-warning" />
|
||||||
|
<h3 className="text-lg font-semibold">监控权限</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="text-sm text-default-600">{accessDenied}</div>
|
||||||
|
<div className="text-xs text-default-500 mt-2">
|
||||||
|
如需使用监控功能,请联系管理员在用户页面授予监控权限。
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DETAIL VIEW =====
|
||||||
|
if (detailTunnelId && detailTunnel) {
|
||||||
|
const quality = tunnelQualities[detailTunnelId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Button size="sm" variant="flat" onPress={() => {
|
||||||
|
setDetailTunnelId(null);
|
||||||
|
setTunnelMetrics([]);
|
||||||
|
}}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||||
|
返回隧道列表
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowRightLeft className={`w-5 h-5 ${detailTunnel.status === 1 ? "text-success" : "text-default-400"}`} />
|
||||||
|
<h3 className="text-lg font-semibold">{detailTunnel.name}</h3>
|
||||||
|
<Chip size="sm" color={detailTunnel.status === 1 ? "success" : "danger"} variant="flat">
|
||||||
|
{detailTunnel.status === 1 ? "启用" : "禁用"}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quality KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
|
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||||
|
<span className="text-[11px] text-default-500 mb-1.5">入口 → 出口 延迟</span>
|
||||||
|
<span className={`text-sm font-semibold font-mono ${quality?.entryToExitLatency !== undefined ? "text-primary" : ""}`}>
|
||||||
|
{quality?.loading ? "检测中..." : quality?.entryToExitLatency !== undefined ? `${quality.entryToExitLatency.toFixed(0)}ms` : "-"}
|
||||||
|
</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
|
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||||
|
<span className="text-[11px] text-default-500 mb-1.5">出口 → Bing 延迟</span>
|
||||||
|
<span className={`text-sm font-semibold font-mono ${quality?.exitToBingLatency !== undefined ? "text-success" : ""}`}>
|
||||||
|
{quality?.loading ? "检测中..." : quality?.exitToBingLatency !== undefined ? `${quality.exitToBingLatency.toFixed(0)}ms` : "-"}
|
||||||
|
</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
|
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||||
|
<span className="text-[11px] text-default-500 mb-1.5">入口 → 出口 丢包</span>
|
||||||
|
<span className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}>
|
||||||
|
{quality?.loading ? "检测中..." : quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"}
|
||||||
|
</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
|
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||||
|
<span className="text-[11px] text-default-500 mb-1.5">出口 → Bing 丢包</span>
|
||||||
|
<span className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}>
|
||||||
|
{quality?.loading ? "检测中..." : quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
|
||||||
|
</span>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagnose Button */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
isLoading={quality?.loading}
|
||||||
|
onPress={() => diagnoseTunnelQuality(detailTunnelId)}
|
||||||
|
>
|
||||||
|
<Stethoscope className="w-4 h-4 mr-1" />
|
||||||
|
{quality?.timestamp ? "重新检测质量" : "检测隧道质量"}
|
||||||
|
</Button>
|
||||||
|
{quality?.timestamp && (
|
||||||
|
<span className="text-xs text-default-500">
|
||||||
|
上次检测: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{quality?.error && (
|
||||||
|
<span className="text-xs text-danger">{quality.error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagnosis Details */}
|
||||||
|
{quality?.results && quality.results.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">诊断详情</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Table aria-label="诊断结果">
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>描述</TableColumn>
|
||||||
|
<TableColumn>节点</TableColumn>
|
||||||
|
<TableColumn>目标</TableColumn>
|
||||||
|
<TableColumn>延迟</TableColumn>
|
||||||
|
<TableColumn>丢包</TableColumn>
|
||||||
|
<TableColumn>状态</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{quality.results.map((r, idx) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm">{r.description || "-"}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm font-mono">{r.nodeName || "-"}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{r.targetIp || "-"}{r.targetPort ? `:${r.targetPort}` : ""}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{r.averageTime !== undefined ? `${r.averageTime.toFixed(0)}ms` : "-"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{r.packetLoss !== undefined ? `${r.packetLoss.toFixed(1)}%` : "-"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="sm" color={r.success ? "success" : "danger"} variant="flat">
|
||||||
|
{r.success ? "成功" : "失败"}
|
||||||
|
</Chip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tunnel traffic chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">隧道流量趋势</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
className="w-36"
|
||||||
|
selectedKeys={[String(tunnelRangeMs)]}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const v = Number(Array.from(keys)[0]);
|
||||||
|
if (v > 0) setTunnelRangeMs(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
|
||||||
|
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
|
||||||
|
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
|
||||||
|
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
isLoading={tunnelMetricsLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => detailTunnelId && loadTunnelMetrics(detailTunnelId)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{tunnelMetricsLoading ? (
|
||||||
|
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
||||||
|
) : tunnelMetricsError ? (
|
||||||
|
<div className="text-center py-8 text-danger text-sm">{tunnelMetricsError}</div>
|
||||||
|
) : tunnelMetrics.length > 0 ? (
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer height="100%" width="100%">
|
||||||
|
<LineChart data={tunnelChartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="time" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} tickFormatter={tunnelYAxisTickFormatter} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
formatter={tunnelTooltipFormatter}
|
||||||
|
/>
|
||||||
|
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
|
||||||
|
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-default-500">暂无指标数据</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== LIST/GRID VIEW =====
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-1">
|
||||||
|
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
|
||||||
|
{tunnelStats.diagnosed > 0 && (
|
||||||
|
<Chip color="success" size="sm" variant="flat">已诊断 {tunnelStats.diagnosed}</Chip>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tunnelsError ? (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<div className="text-sm text-default-600">{tunnelsError}</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{viewMode === "grid" ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{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 (
|
||||||
|
<Card
|
||||||
|
key={tunnel.id}
|
||||||
|
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
|
||||||
|
onClick={() => setDetailTunnelId(tunnel.id)}
|
||||||
|
>
|
||||||
|
{/* Top gradient bar */}
|
||||||
|
<div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
||||||
|
|
||||||
|
{/* Decorative background glow */}
|
||||||
|
<div className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
||||||
|
|
||||||
|
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
|
||||||
|
<ArrowRightLeft className={`w-5 h-5 ${isEnabled ? "text-success" : "text-danger"}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<h3 className="font-semibold text-foreground text-sm truncate">{tunnel.name}</h3>
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-default-500 mt-0.5">
|
||||||
|
<span className="font-mono">{isEnabled ? "启用" : "禁用"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{overallQuality && (
|
||||||
|
<Chip size="sm" color={overallQuality.color} variant="flat">
|
||||||
|
{overallQuality.text}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden">
|
||||||
|
{/* Quality metrics */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
入口→出口
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs font-semibold">
|
||||||
|
{quality?.loading ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin inline" />
|
||||||
|
) : quality?.entryToExitLatency !== undefined ? (
|
||||||
|
`${quality.entryToExitLatency.toFixed(0)}ms`
|
||||||
|
) : "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
出口→Bing
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs font-semibold">
|
||||||
|
{quality?.loading ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin inline" />
|
||||||
|
) : quality?.exitToBingLatency !== undefined ? (
|
||||||
|
`${quality.exitToBingLatency.toFixed(0)}ms`
|
||||||
|
) : "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action area */}
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t border-divider/50">
|
||||||
|
{quality?.error ? (
|
||||||
|
<span className="text-[11px] text-danger truncate">{quality.error}</span>
|
||||||
|
) : quality?.timestamp ? (
|
||||||
|
<span className="text-[11px] text-default-500">
|
||||||
|
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-default-400">未检测</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
isLoading={quality?.loading}
|
||||||
|
onPress={() => {
|
||||||
|
diagnoseTunnelQuality(tunnel.id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Stethoscope className="w-4 h-4 text-default-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="w-full">
|
||||||
|
<Table aria-label="隧道列表">
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>状态</TableColumn>
|
||||||
|
<TableColumn>名称</TableColumn>
|
||||||
|
<TableColumn>入口→出口</TableColumn>
|
||||||
|
<TableColumn>出口→Bing</TableColumn>
|
||||||
|
<TableColumn>质量</TableColumn>
|
||||||
|
<TableColumn align="center">操作</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody emptyContent="暂无隧道">
|
||||||
|
{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 (
|
||||||
|
<TableRow key={tunnel.id} className="border-b border-divider/50 last:border-b-0 cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isEnabled ? (
|
||||||
|
<Wifi className="w-3.5 h-3.5 text-success" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="w-3.5 h-3.5 text-danger" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono text-xs whitespace-nowrap">
|
||||||
|
{quality?.loading ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin inline" />
|
||||||
|
) : quality?.entryToExitLatency !== undefined ? (
|
||||||
|
`${quality.entryToExitLatency.toFixed(0)}ms`
|
||||||
|
) : "-"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono text-xs whitespace-nowrap">
|
||||||
|
{quality?.loading ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin inline" />
|
||||||
|
) : quality?.exitToBingLatency !== undefined ? (
|
||||||
|
`${quality.exitToBingLatency.toFixed(0)}ms`
|
||||||
|
) : "-"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{overallQuality ? (
|
||||||
|
<Chip size="sm" color={overallQuality.color} variant="flat">
|
||||||
|
{overallQuality.text}
|
||||||
|
</Chip>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-default-400">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
isLoading={quality?.loading}
|
||||||
|
onPress={() => diagnoseTunnelQuality(tunnel.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Stethoscope className="w-4 h-4 text-default-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user