feat: implement Uptime Kuma style historic quality bars for tunnel monitor (#355)

This commit is contained in:
sagit
2026-03-20 14:17:16 +08:00
committed by GitHub
parent 75cd60ea3e
commit addf8e2089
4 changed files with 191 additions and 59 deletions
+16
View File
@@ -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 `<UptimeHistoryBar />` 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.
+21 -14
View File
@@ -262,6 +262,7 @@ export default function LimitPage() {
{/* 统一卡片网格 */}
{filteredRules.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
@@ -274,27 +275,32 @@ export default function LimitPage() {
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn align="center">操作</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<TableCell className="font-medium text-foreground">{rule.name}</TableCell>
<TableCell>{rule.speed} Mbps</TableCell>
<TableCell>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
rule.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{rule.name}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 justify-center">
<span className="text-sm font-mono text-default-600">
{rule.speed} Mbps
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
color="primary"
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(rule)}
@@ -302,7 +308,7 @@ export default function LimitPage() {
编辑
</Button>
<Button
color="danger"
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
onPress={() => handleDelete(rule)}
@@ -315,6 +321,7 @@ export default function LimitPage() {
)}
</TableBody>
</Table>
</Card>
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => (
@@ -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 (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-[3px]">
{bars.map((q, idx) => {
if (!q) {
return (
<div
key={idx}
className="w-1.5 h-4 rounded-full bg-default-200 dark:bg-default-100/50"
title="暂无数据"
/>
);
}
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 (
<div
key={q.timestamp || idx}
className={`w-1.5 h-4 rounded-full transition-colors cursor-help hover:opacity-80 ${colorClass}`}
title={tooltip}
/>
);
})}
</div>
<div>
<LatencyDisplay value={latestValue} />
</div>
</div>
);
}
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
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<Record<number, TunnelQualityApiItem>>({});
const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
const initialHistoryFetched = useRef(false);
const [qualityLoading, setQualityLoading] = useState(false);
const qualityTimerRef = useRef<number | null>(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 (
<Card
@@ -637,11 +761,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
</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">
@@ -651,14 +770,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<Zap className="w-3 h-3" />
入口→出口
</div>
<LatencyDisplay value={quality?.entryToExitLatency} />
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
</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>
<LatencyDisplay value={quality?.exitToBingLatency} />
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
</div>
</div>
@@ -695,16 +814,12 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<TableColumn>名称</TableColumn>
<TableColumn>入口→出口</TableColumn>
<TableColumn>出口→Bing</TableColumn>
<TableColumn>质量</TableColumn>
<TableColumn>更新时间</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道">
{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 (
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
@@ -721,19 +836,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
</TableCell>
<TableCell>
<LatencyDisplay value={quality?.entryToExitLatency} />
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
</TableCell>
<TableCell>
<LatencyDisplay value={quality?.exitToBingLatency} />
</TableCell>
<TableCell>
{overallQuality ? (
<Chip size="sm" color={overallQuality.color} variant="flat">
{overallQuality.text}
</Chip>
) : (
<span className="text-xs text-default-400">-</span>
)}
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
</TableCell>
<TableCell>
{quality?.timestamp ? (
+22 -19
View File
@@ -1578,6 +1578,7 @@ export default function TunnelPage() {
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
@@ -1598,7 +1599,7 @@ export default function TunnelPage() {
<TableColumn>类型</TableColumn>
<TableColumn>拓扑</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn align="center">操作</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={sortedTunnels}>
{(tunnel) => {
@@ -1616,7 +1617,18 @@ export default function TunnelPage() {
onValueChange={() => toggleSelect(tunnel.id)}
/>
</TableCell> : <TableCell className="w-0 p-0 overflow-hidden text-[0px]"></TableCell>}
<TableCell className="font-medium text-foreground">{tunnel.name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
tunnel.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{tunnel.name}
</span>
</div>
</TableCell>
<TableCell>
<Chip
className={tunnelTypeChipClassName}
@@ -1656,40 +1668,30 @@ export default function TunnelPage() {
</div>
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
isIconOnly
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(tunnel)}
color="primary"
>
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
编辑
</Button>
<Button
isIconOnly
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
size="sm"
variant="flat"
color="warning"
onPress={() => handleDiagnose(tunnel)}
>
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path clipRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" fillRule="evenodd" />
</svg>
诊断
</Button>
<Button
isIconOnly
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(tunnel)}
>
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path clipRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" fillRule="evenodd" />
<path clipRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" fillRule="evenodd" />
</svg>
删除
</Button>
</div>
</TableCell>
@@ -1698,6 +1700,7 @@ export default function TunnelPage() {
}}
</TableBody>
</Table>
</Card>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext