diff --git a/plans/056-tunnel-uptime-kuma-bars.md b/plans/056-tunnel-uptime-kuma-bars.md new file mode 100644 index 0000000..fb13643 --- /dev/null +++ b/plans/056-tunnel-uptime-kuma-bars.md @@ -0,0 +1,16 @@ +# Tunnel Quality Uptime Kuma Display +**Plan ID**: 056-tunnel-uptime-kuma-bars.md + +## Objective +The goal is to modify the "Monitor - Tunnel" UI page to remove the isolated "Quality" column/chip, and replace the specific latency readouts for entry->exit and exit->Bing with an Uptime Kuma style row of visual history bars. + +## Steps +- [x] Remove the individual "Quality" column from the tunnel monitor table view. +- [x] Remove the individual "Quality" chip from the tunnel monitor grid view. +- [x] Implement `` to display a historical sequence of up to 30 metrics, coloring by latency (success, warning, danger) and packet loss (danger). +- [x] Modify the front-end to poll/load initial tunnel quality history, effectively padding the history bars instead of them starting empty. +- [x] Update `getMonitorTunnelQuality` auto-polling locally to append strictly to the local history state, truncating appropriately. +- [x] Retain current latency display next to or below the Uptime bars to allow numerical visibility. + +## Status +Completed. The interface will now load history for all displayed tunnels upon open, and continue tracking with bars appending in real time every 10-second polling interval. diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx index 6e68198..f22e074 100644 --- a/vite-frontend/src/pages/limit.tsx +++ b/vite-frontend/src/pages/limit.tsx @@ -262,6 +262,7 @@ export default function LimitPage() { {/* 统一卡片网格 */} {filteredRules.length > 0 ? ( viewMode === "list" ? ( + 规则名称速度限制 - 状态 - 操作 + 操作 {(rule) => ( - {rule.name} - {rule.speed} Mbps - - {rule.status === 1 ? "运行" : "异常"} - +
+
+ + {rule.name} + +
-
+ + {rule.speed} Mbps + + + +
+
) : ( {filteredRules.map((rule) => ( diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx index 9fc1a93..f3ed848 100644 --- a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx +++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx @@ -32,7 +32,7 @@ import { getMonitorTunnelQuality, getMonitorTunnelQualityHistory, } from "@/api"; -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"; @@ -109,6 +109,79 @@ function LiveDot() { ); } +const UPTIME_KUMA_BARS_COUNT = 30; + +function UptimeHistoryBar({ + history = [], + type, + latestValue, +}: { + history: TunnelQualityApiItem[]; + type: "entryToExit" | "exitToBing"; + latestValue?: number; +}) { + const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => { + const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i; + if (historyIndex >= 0 && historyIndex < history.length) { + return history[historyIndex]; + } + return null; + }); + + return ( +
+
+ {bars.map((q, idx) => { + if (!q) { + return ( +
+ ); + } + const isEntry = type === "entryToExit"; + const latency = isEntry ? q.entryToExitLatency : q.exitToBingLatency; + const loss = isEntry ? q.entryToExitLoss : q.exitToBingLoss; + const timeStr = new Date(q.timestamp).toLocaleTimeString("zh-CN"); + + let colorClass = "bg-success"; + let statusText = "优秀"; + + if (q.errorMessage || (loss !== undefined && loss > 0)) { + colorClass = "bg-danger"; + statusText = q.errorMessage ? "错误" : `丢包 ${loss?.toFixed(1)}%`; + } else if (latency < 0) { + colorClass = "bg-danger"; + statusText = "探测失败"; + } else if (latency > 200) { + colorClass = "bg-danger"; + statusText = "极差"; + } else if (latency > 100) { + colorClass = "bg-warning"; + statusText = "较差"; + } + + const displayLatency = latency >= 0 ? `${latency.toFixed(0)}ms` : "-"; + const tooltip = `${timeStr} | ${displayLatency} | ${statusText}`; + + return ( +
+ ); + })} +
+
+ +
+
+ ); +} + export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) { const [tunnels, setTunnels] = useState([]); const [tunnelsLoading, setTunnelsLoading] = useState(false); @@ -117,6 +190,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) // Quality data from backend periodic probing (latest per tunnel) const [qualityMap, setQualityMap] = useState>({}); + const [qualityHistoryMap, setQualityHistoryMap] = useState>({}); + const initialHistoryFetched = useRef(false); const [qualityLoading, setQualityLoading] = useState(false); const qualityTimerRef = useRef(null); @@ -178,6 +253,45 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) return () => window.clearInterval(timer); }, [loadTunnels]); + // --- Initial history load --- + useEffect(() => { + if (tunnels.length > 0 && !initialHistoryFetched.current) { + initialHistoryFetched.current = true; + const fetchHistory = async () => { + const end = Date.now(); + // Fetch last 5 minutes (about 30 points) + const start = end - 5 * 60 * 1000; + + const promises = tunnels.map((t) => + getMonitorTunnelQualityHistory(t.id, start, end).then(res => { + if (res.code === 0 && res.data) { + return { id: t.id, data: res.data }; + } + return { id: t.id, data: [] }; + }).catch(() => ({ id: t.id, data: [] })) + ); + + const results = await Promise.all(promises); + setQualityHistoryMap(prev => { + const nextMap = { ...prev }; + results.forEach(r => { + const existing = nextMap[r.id] || []; + const merged = [...r.data, ...existing].sort((a,b) => a.timestamp - b.timestamp); + const unique: TunnelQualityApiItem[] = []; + for (const item of merged) { + if (unique.length === 0 || unique[unique.length-1].timestamp !== item.timestamp) { + unique.push(item); + } + } + nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT); + }); + return nextMap; + }); + }; + void fetchHistory(); + } + }, [tunnels]); + // --- Load quality snapshots (auto-polling every 10s) --- const loadQuality = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; @@ -190,6 +304,19 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) map[q.tunnelId] = q; } setQualityMap(map); + + // Accumulate locally + setQualityHistoryMap(prev => { + const nextMap = { ...prev }; + for (const q of response.data) { + const currentArr = nextMap[q.tunnelId] || []; + const lastItem = currentArr.length > 0 ? currentArr[currentArr.length - 1] : null; + if (!lastItem || lastItem.timestamp !== q.timestamp) { + nextMap[q.tunnelId] = [...currentArr, q].slice(-UPTIME_KUMA_BARS_COUNT); + } + } + return nextMap; + }); } } catch { // Silently ignore quality load failures @@ -609,9 +736,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {tunnels.map((tunnel) => { const quality = qualityMap[tunnel.id]; const isEnabled = tunnel.status === 1; - const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0 - ? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0) - : null; return (
- {overallQuality && ( - - {overallQuality.text} - - )} @@ -651,14 +770,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) 入口→出口 - +
出口→Bing
- +
@@ -695,16 +814,12 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) 名称 入口→出口 出口→Bing - 质量 更新时间 {tunnels.map((tunnel) => { const quality = qualityMap[tunnel.id]; const isEnabled = tunnel.status === 1; - const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0 - ? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0) - : null; return ( setDetailTunnelId(tunnel.id)}> @@ -721,19 +836,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {tunnel.name} - + - - - - {overallQuality ? ( - - {overallQuality.text} - - ) : ( - - - )} + {quality?.timestamp ? ( diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index dfb913b..fd68cd8 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -1578,6 +1578,7 @@ export default function TunnelPage() { {/* 隧道卡片网格 */} {tunnels.length > 0 ? ( viewMode === "list" ? ( + 类型拓扑流量统计 - 操作 + 操作 {(tunnel) => { @@ -1616,7 +1617,18 @@ export default function TunnelPage() { onValueChange={() => toggleSelect(tunnel.id)} /> : } - {tunnel.name} + +
+
+ + {tunnel.name} + +
+ -
+
@@ -1698,6 +1700,7 @@ export default function TunnelPage() { }}
+
) : (