mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat: implement Uptime Kuma style historic quality bars for tunnel monitor (#355)
This commit is contained in:
@@ -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.
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user