mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-13 20:46:38 +08:00
style(frontend): sync remaining glass UI theme files and cyberpunk theme
This commit is contained in:
@@ -5,7 +5,13 @@ import type {
|
||||
TunnelQualityHopApiItem,
|
||||
} from "@/api/types";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -35,7 +41,6 @@ import {
|
||||
getMonitorTunnelQualityHistory,
|
||||
getConfigByName,
|
||||
} from "@/api";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
@@ -54,7 +59,8 @@ interface TunnelMonitorViewProps {
|
||||
}
|
||||
|
||||
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
|
||||
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
|
||||
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY =
|
||||
"monitor_tunnel_quality_enabled";
|
||||
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
|
||||
"monitorTunnelQualityEnabledChanged";
|
||||
|
||||
@@ -78,7 +84,13 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
||||
});
|
||||
};
|
||||
|
||||
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
|
||||
function LatencyDisplay({
|
||||
value,
|
||||
loading,
|
||||
}: {
|
||||
value?: number;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
if (loading) {
|
||||
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
|
||||
}
|
||||
@@ -87,11 +99,16 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean
|
||||
}
|
||||
const ms = value.toFixed(0);
|
||||
let colorClass = "text-success";
|
||||
|
||||
if (value > 200) colorClass = "text-danger";
|
||||
else if (value > 100) colorClass = "text-warning";
|
||||
else if (value > 50) colorClass = "text-primary";
|
||||
|
||||
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
|
||||
return (
|
||||
<span className={`font-mono text-xs font-semibold ${colorClass}`}>
|
||||
{ms}ms
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveDot() {
|
||||
@@ -116,9 +133,11 @@ function UptimeHistoryBar({
|
||||
}) {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -183,13 +202,20 @@ const TIME_RANGE_OPTIONS = [
|
||||
{ key: String(24 * 60 * 60 * 1000), label: "24小时" },
|
||||
];
|
||||
|
||||
function TimeRangeSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||
function TimeRangeSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
className="w-36"
|
||||
selectedKeys={[String(value)]}
|
||||
onSelectionChange={(keys) => {
|
||||
const v = Number(Array.from(keys)[0]);
|
||||
|
||||
if (v > 0) onChange(v);
|
||||
}}
|
||||
>
|
||||
@@ -205,13 +231,23 @@ interface QualityChartCardProps {
|
||||
onRangeChange: (v: number) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
data: Array<{ time: string; entryToExit: number | null; exitToBing: number | null }>;
|
||||
data: Array<{
|
||||
time: string;
|
||||
entryToExit: number | null;
|
||||
exitToBing: number | null;
|
||||
}>;
|
||||
tunnelId: number;
|
||||
onRefresh: (id: number) => void;
|
||||
}
|
||||
|
||||
const QualityChartCard = React.memo(function QualityChartCard({
|
||||
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
|
||||
rangeMs,
|
||||
onRangeChange,
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
tunnelId,
|
||||
onRefresh,
|
||||
}: QualityChartCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -219,7 +255,12 @@ const QualityChartCard = React.memo(function QualityChartCard({
|
||||
<h3 className="text-lg font-semibold">质量趋势</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
|
||||
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
|
||||
<Button
|
||||
isLoading={loading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => onRefresh(tunnelId)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
@@ -227,38 +268,75 @@ const QualityChartCard = React.memo(function QualityChartCard({
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
||||
<div className="flex justify-center py-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-danger text-sm">{error}</div>
|
||||
) : data.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<CartesianGrid opacity={0.3} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
||||
<YAxis
|
||||
fontSize={11}
|
||||
label={{
|
||||
value: "延迟 (ms)",
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
style: { fontSize: 11, fill: "#888" },
|
||||
}}
|
||||
tick={{ fill: "#888" }}
|
||||
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
|
||||
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
contentStyle={{
|
||||
backgroundColor: "rgba(0,0,0,0.85)",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: unknown, name: string) => {
|
||||
const n = Number(value);
|
||||
|
||||
if (!Number.isFinite(n)) return "-";
|
||||
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
|
||||
const label =
|
||||
name === "entryToExit"
|
||||
? "入口→出口"
|
||||
: name === "exitToBing"
|
||||
? "出口→Bing"
|
||||
: name;
|
||||
|
||||
return [`${n.toFixed(1)}ms`, label];
|
||||
}}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
/>
|
||||
<Line
|
||||
connectNulls
|
||||
dataKey="entryToExit"
|
||||
dot={false}
|
||||
name="entryToExit"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
connectNulls
|
||||
dataKey="exitToBing"
|
||||
dot={false}
|
||||
name="exitToBing"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line connectNulls dataKey="entryToExit" dot={false} name="entryToExit" stroke="#10b981" strokeWidth={2} type="monotone" />
|
||||
<Line connectNulls dataKey="exitToBing" dot={false} name="exitToBing" stroke="#3b82f6" strokeWidth={2} type="monotone" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
|
||||
<div className="text-center py-8 text-default-500">
|
||||
暂无质量历史数据
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -270,20 +348,33 @@ interface TrafficChartCardProps {
|
||||
onRangeChange: (v: number) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
data: Array<{ time: string; bytesIn: number; bytesOut: number; connections: number }>;
|
||||
data: Array<{
|
||||
time: string;
|
||||
bytesIn: number;
|
||||
bytesOut: number;
|
||||
connections: number;
|
||||
}>;
|
||||
tunnelId: number;
|
||||
onRefresh: (id: number) => void;
|
||||
}
|
||||
|
||||
const TrafficChartCard = React.memo(function TrafficChartCard({
|
||||
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
|
||||
rangeMs,
|
||||
onRangeChange,
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
tunnelId,
|
||||
onRefresh,
|
||||
}: TrafficChartCardProps) {
|
||||
const yFormatter = (value: unknown) => {
|
||||
const n = Number(value);
|
||||
|
||||
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(n) / Math.log(k));
|
||||
|
||||
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
@@ -293,7 +384,12 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
|
||||
<h3 className="text-lg font-semibold">流量趋势</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
|
||||
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
|
||||
<Button
|
||||
isLoading={loading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => onRefresh(tunnelId)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
@@ -301,23 +397,48 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
||||
<div className="flex justify-center py-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-danger text-sm">{error}</div>
|
||||
) : data.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<CartesianGrid opacity={0.3} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
||||
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={yFormatter} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
formatter={yFormatter}
|
||||
<YAxis
|
||||
fontSize={11}
|
||||
tick={{ fill: "#888" }}
|
||||
tickFormatter={yFormatter}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "rgba(0,0,0,0.85)",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={yFormatter}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
/>
|
||||
<Line
|
||||
dataKey="bytesIn"
|
||||
dot={false}
|
||||
name="入站流量"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
dataKey="bytesOut"
|
||||
dot={false}
|
||||
name="出站流量"
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<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>
|
||||
@@ -333,6 +454,7 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
|
||||
if (!hopsStr) return null;
|
||||
|
||||
let hops: TunnelQualityHopApiItem[] = [];
|
||||
|
||||
try {
|
||||
hops = JSON.parse(hopsStr);
|
||||
} catch {
|
||||
@@ -353,28 +475,49 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
|
||||
<div className="flex items-center overflow-x-auto pb-2 py-2">
|
||||
{hops.map((hop, index) => {
|
||||
const hasError = hop.latency < 0 || hop.loss > 0;
|
||||
const colorClass = hop.latency < 0 ? "text-danger" : (hop.loss > 0 ? "text-warning" : "text-success");
|
||||
const colorClass =
|
||||
hop.latency < 0
|
||||
? "text-danger"
|
||||
: hop.loss > 0
|
||||
? "text-warning"
|
||||
: "text-success";
|
||||
const borderColor = hasError ? "border-danger" : "";
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{index === 0 && (
|
||||
<Chip size="sm" variant="flat" className="shrink-0 font-mono shadow-sm">
|
||||
<Chip
|
||||
className="shrink-0 font-mono shadow-sm"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{hop.fromNodeName}
|
||||
</Chip>
|
||||
)}
|
||||
<div className="flex flex-col items-center justify-center min-w-[70px] mx-1 shrink-0 relative">
|
||||
<span className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}>
|
||||
<span
|
||||
className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}
|
||||
>
|
||||
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
|
||||
</span>
|
||||
<div className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}>
|
||||
<ArrowRight className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`} />
|
||||
<div
|
||||
className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}
|
||||
>
|
||||
<ArrowRight
|
||||
className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}>
|
||||
<span
|
||||
className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}
|
||||
>
|
||||
{hop.loss.toFixed(0)}% 丢包
|
||||
</span>
|
||||
</div>
|
||||
<Chip size="sm" variant="flat" className={`shrink-0 font-mono shadow-sm ${borderColor}`}>
|
||||
<Chip
|
||||
className={`shrink-0 font-mono shadow-sm ${borderColor}`}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{hop.toNodeName}
|
||||
</Chip>
|
||||
</React.Fragment>
|
||||
@@ -386,15 +529,21 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
|
||||
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);
|
||||
|
||||
// Quality data from backend periodic probing (latest per tunnel)
|
||||
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
|
||||
const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
|
||||
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);
|
||||
@@ -405,20 +554,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
|
||||
|
||||
// Quality history for chart (mirrors service monitor results)
|
||||
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>([]);
|
||||
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>(
|
||||
[],
|
||||
);
|
||||
const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false);
|
||||
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(null);
|
||||
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000);
|
||||
|
||||
// Tunnel traffic metrics for chart
|
||||
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
|
||||
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
|
||||
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
|
||||
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
|
||||
|
||||
// --- Load tunnel list ---
|
||||
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
|
||||
if (!silent) setTunnelsLoading(true);
|
||||
try {
|
||||
const response = await getMonitorTunnels();
|
||||
@@ -427,12 +583,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
setAccessDenied(null);
|
||||
setTunnelsError(null);
|
||||
setTunnels(response.data);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.code === 403) {
|
||||
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
|
||||
setTunnelsError(null);
|
||||
setTunnels([]);
|
||||
|
||||
return;
|
||||
}
|
||||
setTunnelsError(response.msg || "加载隧道列表失败");
|
||||
@@ -452,6 +610,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
const response = await getConfigByName(
|
||||
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
|
||||
);
|
||||
|
||||
setMonitorTunnelQualityEnabled(
|
||||
typeof response.data?.value === "string"
|
||||
? response.data.value === "true"
|
||||
@@ -477,7 +636,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
|
||||
useEffect(() => {
|
||||
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
|
||||
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled;
|
||||
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail
|
||||
?.enabled;
|
||||
|
||||
if (typeof enabled === "boolean") {
|
||||
setMonitorTunnelQualityEnabled(enabled);
|
||||
if (!enabled) {
|
||||
@@ -510,31 +671,44 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
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: [] }))
|
||||
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 => {
|
||||
|
||||
setQualityHistoryMap((prev) => {
|
||||
const nextMap = { ...prev };
|
||||
results.forEach(r => {
|
||||
|
||||
results.forEach((r) => {
|
||||
const existing = nextMap[r.id] || [];
|
||||
const merged = [...r.data, ...existing].sort((a,b) => a.timestamp - b.timestamp);
|
||||
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);
|
||||
}
|
||||
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]);
|
||||
@@ -542,27 +716,36 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
// --- Load quality snapshots (auto-polling every 10s) ---
|
||||
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
|
||||
if (!silent) setQualityLoading(true);
|
||||
try {
|
||||
const response = await getMonitorTunnelQuality();
|
||||
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
const map: Record<number, TunnelQualityApiItem> = {};
|
||||
|
||||
for (const q of response.data) {
|
||||
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;
|
||||
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 {
|
||||
@@ -586,6 +769,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
window.clearInterval(qualityTimerRef.current);
|
||||
qualityTimerRef.current = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -605,19 +789,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
const loadQualityHistory = useCallback(
|
||||
async (tunnelId: number, options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
|
||||
if (!silent) setQualityHistoryLoading(true);
|
||||
try {
|
||||
const end = Date.now();
|
||||
const start = end - qualityRangeMs;
|
||||
const response = await getMonitorTunnelQualityHistory(tunnelId, start, end);
|
||||
const response = await getMonitorTunnelQualityHistory(
|
||||
tunnelId,
|
||||
start,
|
||||
end,
|
||||
);
|
||||
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
setQualityHistoryError(null);
|
||||
setQualityHistory(response.data);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.code === 403) {
|
||||
setAccessDenied(response.msg || "暂无监控权限");
|
||||
|
||||
return;
|
||||
}
|
||||
setQualityHistoryError(response.msg || "加载质量历史失败");
|
||||
@@ -637,6 +828,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
const loadTunnelMetrics = useCallback(
|
||||
async (tunnelId: number, options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
|
||||
if (!silent) setTunnelMetricsLoading(true);
|
||||
try {
|
||||
const end = Date.now();
|
||||
@@ -648,7 +840,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
const ordered = [...response.data].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
|
||||
setTunnelMetrics(ordered);
|
||||
|
||||
return;
|
||||
}
|
||||
setTunnelMetricsError(response.msg || "加载流量数据失败");
|
||||
@@ -695,29 +889,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
|
||||
// Memoize chart data so React.memo sub-components see stable references
|
||||
const qualityChartData = useMemo(
|
||||
() => qualityHistory.map((q) => ({
|
||||
time: formatTimestamp(q.timestamp, qualityRangeMs),
|
||||
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
|
||||
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
|
||||
entryToExitLoss: q.entryToExitLoss,
|
||||
exitToBingLoss: q.exitToBingLoss,
|
||||
})),
|
||||
() =>
|
||||
qualityHistory.map((q) => ({
|
||||
time: formatTimestamp(q.timestamp, qualityRangeMs),
|
||||
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
|
||||
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
|
||||
entryToExitLoss: q.entryToExitLoss,
|
||||
exitToBingLoss: q.exitToBingLoss,
|
||||
})),
|
||||
[qualityHistory, qualityRangeMs],
|
||||
);
|
||||
|
||||
const tunnelChartData = useMemo(
|
||||
() => tunnelMetrics.map((m) => ({
|
||||
time: formatTimestamp(m.timestamp, tunnelRangeMs),
|
||||
bytesIn: m.bytesIn,
|
||||
bytesOut: m.bytesOut,
|
||||
connections: m.connections,
|
||||
})),
|
||||
() =>
|
||||
tunnelMetrics.map((m) => ({
|
||||
time: formatTimestamp(m.timestamp, tunnelRangeMs),
|
||||
bytesIn: m.bytesIn,
|
||||
bytesOut: m.bytesOut,
|
||||
connections: m.connections,
|
||||
})),
|
||||
[tunnelMetrics, tunnelRangeMs],
|
||||
);
|
||||
|
||||
const detailTunnel = detailTunnelId != null
|
||||
? tunnels.find((t) => t.id === detailTunnelId)
|
||||
: null;
|
||||
const detailTunnel =
|
||||
detailTunnelId != null
|
||||
? tunnels.find((t) => t.id === detailTunnelId)
|
||||
: null;
|
||||
|
||||
// Aggregate stats
|
||||
const tunnelStats = useMemo(() => {
|
||||
@@ -729,9 +926,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
// Last quality update timestamp
|
||||
const lastQualityUpdate = useMemo(() => {
|
||||
let latest = 0;
|
||||
|
||||
for (const q of Object.values(qualityMap)) {
|
||||
if (q.timestamp > latest) latest = q.timestamp;
|
||||
}
|
||||
|
||||
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
|
||||
}, [qualityMap]);
|
||||
|
||||
@@ -759,18 +958,28 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button size="sm" variant="flat" onPress={() => {
|
||||
setDetailTunnelId(null);
|
||||
setQualityHistory([]);
|
||||
setTunnelMetrics([]);
|
||||
}}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => {
|
||||
setDetailTunnelId(null);
|
||||
setQualityHistory([]);
|
||||
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"}`} />
|
||||
<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">
|
||||
<Chip
|
||||
color={detailTunnel.status === 1 ? "success" : "danger"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{detailTunnel.status === 1 ? "启用" : "禁用"}
|
||||
</Chip>
|
||||
</div>
|
||||
@@ -784,7 +993,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<Zap className="w-3 h-3" />
|
||||
入口 → 出口 延迟
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.entryToExitLatency} loading={qualityLoading} />
|
||||
<LatencyDisplay
|
||||
loading={qualityLoading}
|
||||
value={quality?.entryToExitLatency}
|
||||
/>
|
||||
</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">
|
||||
@@ -793,22 +1005,37 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<Globe className="w-3 h-3" />
|
||||
出口 → Bing 延迟
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.exitToBingLatency} loading={qualityLoading} />
|
||||
<LatencyDisplay
|
||||
loading={qualityLoading}
|
||||
value={quality?.exitToBingLatency}
|
||||
/>
|
||||
</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?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"}
|
||||
<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?.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?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
|
||||
<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?.exitToBingLoss !== undefined
|
||||
? `${quality.exitToBingLoss.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -829,7 +1056,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
)}
|
||||
{quality?.timestamp && (
|
||||
<span className="text-default-400">
|
||||
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
· 最近更新:{" "}
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
{quality?.errorMessage && (
|
||||
@@ -844,23 +1072,23 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
|
||||
{/* ====== Quality History Chart — isolated with React.memo ====== */}
|
||||
<QualityChartCard
|
||||
rangeMs={qualityRangeMs}
|
||||
onRangeChange={setQualityRangeMs}
|
||||
loading={qualityHistoryLoading}
|
||||
error={qualityHistoryError}
|
||||
data={qualityChartData}
|
||||
error={qualityHistoryError}
|
||||
loading={qualityHistoryLoading}
|
||||
rangeMs={qualityRangeMs}
|
||||
tunnelId={detailTunnelId}
|
||||
onRangeChange={setQualityRangeMs}
|
||||
onRefresh={loadQualityHistory}
|
||||
/>
|
||||
|
||||
{/* ====== Traffic Chart — isolated with React.memo ====== */}
|
||||
<TrafficChartCard
|
||||
rangeMs={tunnelRangeMs}
|
||||
onRangeChange={setTunnelRangeMs}
|
||||
loading={tunnelMetricsLoading}
|
||||
error={tunnelMetricsError}
|
||||
data={tunnelChartData}
|
||||
error={tunnelMetricsError}
|
||||
loading={tunnelMetricsLoading}
|
||||
rangeMs={tunnelRangeMs}
|
||||
tunnelId={detailTunnelId}
|
||||
onRangeChange={setTunnelRangeMs}
|
||||
onRefresh={loadTunnelMetrics}
|
||||
/>
|
||||
</div>
|
||||
@@ -871,7 +1099,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
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>
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
隧道 {tunnelStats.enabled}/{tunnelStats.total}
|
||||
</Chip>
|
||||
{lastQualityUpdate ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-default-500">
|
||||
{monitorTunnelQualityEnabled ? (
|
||||
@@ -893,7 +1123,12 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ml-auto">
|
||||
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
|
||||
<Button
|
||||
isLoading={tunnelsLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => loadTunnels()}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
@@ -920,21 +1155,33 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
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)}
|
||||
>
|
||||
<div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
||||
<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"}`} />
|
||||
<div
|
||||
className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`}
|
||||
/>
|
||||
<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"}`} />
|
||||
<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"}`} />
|
||||
<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>
|
||||
<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>
|
||||
<span className="font-mono">
|
||||
{isEnabled ? "启用" : "禁用"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -947,20 +1194,30 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<Zap className="w-3 h-3" />
|
||||
入口→出口
|
||||
</div>
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
|
||||
<UptimeHistoryBar
|
||||
history={qualityHistoryMap[tunnel.id]}
|
||||
latestValue={quality?.entryToExitLatency}
|
||||
type="entryToExit"
|
||||
/>
|
||||
</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>
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
||||
<UptimeHistoryBar
|
||||
history={qualityHistoryMap[tunnel.id]}
|
||||
latestValue={quality?.exitToBingLatency}
|
||||
type="exitToBing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2 border-t border-divider/50">
|
||||
{quality?.errorMessage ? (
|
||||
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
|
||||
<span className="text-[11px] text-danger truncate">
|
||||
{quality.errorMessage}
|
||||
</span>
|
||||
) : quality?.timestamp ? (
|
||||
<span className="text-[11px] text-default-500 flex items-center gap-1">
|
||||
{monitorTunnelQualityEnabled ? (
|
||||
@@ -968,11 +1225,15 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
) : (
|
||||
<WifiOff className="w-3 h-3 text-warning" />
|
||||
)}
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
{new Date(quality.timestamp).toLocaleTimeString(
|
||||
"zh-CN",
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-default-400">
|
||||
{monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
|
||||
{monitorTunnelQualityEnabled
|
||||
? "等待探测..."
|
||||
: "实时检测已关闭"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -987,7 +1248,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
aria-label="隧道列表"
|
||||
className="overflow-x-auto min-w-full"
|
||||
classNames={{
|
||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
||||
wrapper:
|
||||
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||
@@ -1006,7 +1268,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
const isEnabled = tunnel.status === 1;
|
||||
|
||||
return (
|
||||
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
|
||||
<TableRow
|
||||
key={tunnel.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailTunnelId(tunnel.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isEnabled ? (
|
||||
@@ -1017,13 +1283,23 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
|
||||
<span className="font-semibold text-sm whitespace-nowrap">
|
||||
{tunnel.name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
|
||||
<UptimeHistoryBar
|
||||
history={qualityHistoryMap[tunnel.id]}
|
||||
latestValue={quality?.entryToExitLatency}
|
||||
type="entryToExit"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
||||
<UptimeHistoryBar
|
||||
history={qualityHistoryMap[tunnel.id]}
|
||||
latestValue={quality?.exitToBingLatency}
|
||||
type="exitToBing"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{quality?.timestamp ? (
|
||||
@@ -1033,7 +1309,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5 text-warning" />
|
||||
)}
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
{new Date(quality.timestamp).toLocaleTimeString(
|
||||
"zh-CN",
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-default-400">
|
||||
|
||||
Reference in New Issue
Block a user