mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Update monitor rendering (#370)
Merge all changes into main for RC2 release.
This commit is contained in:
@@ -36,7 +36,7 @@ func TestRecordNodeMetric(t *testing.T) {
|
|||||||
svc.RecordNodeMetric(1, info)
|
svc.RecordNodeMetric(1, info)
|
||||||
svc.flushNodeMetrics()
|
svc.flushNodeMetrics()
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func TestRecordNodeMetricAutoFlush(t *testing.T) {
|
|||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ func TestIngestionServiceStart(t *testing.T) {
|
|||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ func TestGetMetricsWithTimeRange(t *testing.T) {
|
|||||||
|
|
||||||
svc.flushNodeMetrics()
|
svc.flushNodeMetrics()
|
||||||
|
|
||||||
metrics, err := svc.GetMetrics(1, 0, now+1000)
|
metrics, err := svc.GetMetrics(1, now-60000, now+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -224,7 +224,7 @@ func TestPruneMetrics(t *testing.T) {
|
|||||||
|
|
||||||
svc.pruneMetrics()
|
svc.pruneMetrics()
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ func TestMultipleNodes(t *testing.T) {
|
|||||||
svc.flushNodeMetrics()
|
svc.flushNodeMetrics()
|
||||||
|
|
||||||
for nodeID := int64(1); nodeID <= 3; nodeID++ {
|
for nodeID := int64(1); nodeID <= 3; nodeID++ {
|
||||||
metrics, err := r.GetNodeMetrics(nodeID, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(nodeID, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics for node %d: %v", nodeID, err)
|
t.Fatalf("get metrics for node %d: %v", nodeID, err)
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ func TestZeroValues(t *testing.T) {
|
|||||||
svc.RecordNodeMetric(1, info)
|
svc.RecordNodeMetric(1, info)
|
||||||
svc.flushNodeMetrics()
|
svc.flushNodeMetrics()
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3320,15 +3320,61 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model
|
|||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rangeMs := endMs - startMs
|
||||||
|
const maxRawRangeMs = int64(60 * 60 * 1000) // 1 hour — return raw data for short ranges
|
||||||
|
const targetPoints = 500 // target number of chart points for downsampled data
|
||||||
|
|
||||||
|
// For short ranges, return raw data (full resolution).
|
||||||
|
if rangeMs <= maxRawRangeMs {
|
||||||
|
var metrics []model.NodeMetric
|
||||||
|
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
||||||
|
Order("timestamp ASC").
|
||||||
|
Limit(5000).
|
||||||
|
Find(&metrics).Error
|
||||||
|
return metrics, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// For longer ranges, downsample via SQL aggregation to keep the response small and fast.
|
||||||
|
bucketMs := rangeMs / targetPoints
|
||||||
|
if bucketMs < 1000 {
|
||||||
|
bucketMs = 1000 // minimum 1-second buckets
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketExpr := fmt.Sprintf("(timestamp / %d * %d)", bucketMs, bucketMs)
|
||||||
|
groupExpr := fmt.Sprintf("timestamp / %d", bucketMs)
|
||||||
|
|
||||||
var metrics []model.NodeMetric
|
var metrics []model.NodeMetric
|
||||||
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
err := r.db.Model(&model.NodeMetric{}).
|
||||||
Order("timestamp DESC").
|
Select(
|
||||||
Limit(5000).
|
fmt.Sprintf(
|
||||||
Find(&metrics).Error
|
"? AS node_id, "+
|
||||||
if len(metrics) > 1 {
|
"CAST(%s AS INTEGER) AS timestamp, "+
|
||||||
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
|
"AVG(cpu_usage) AS cpu_usage, "+
|
||||||
metrics[i], metrics[j] = metrics[j], metrics[i]
|
"AVG(mem_usage) AS mem_usage, "+
|
||||||
}
|
"AVG(disk_usage) AS disk_usage, "+
|
||||||
|
"CAST(AVG(net_in_bytes) AS INTEGER) AS net_in_bytes, "+
|
||||||
|
"CAST(AVG(net_out_bytes) AS INTEGER) AS net_out_bytes, "+
|
||||||
|
"CAST(AVG(net_in_speed) AS INTEGER) AS net_in_speed, "+
|
||||||
|
"CAST(AVG(net_out_speed) AS INTEGER) AS net_out_speed, "+
|
||||||
|
"AVG(load1) AS load1, "+
|
||||||
|
"AVG(load5) AS load5, "+
|
||||||
|
"AVG(load15) AS load15, "+
|
||||||
|
"CAST(AVG(tcp_conns) AS INTEGER) AS tcp_conns, "+
|
||||||
|
"CAST(AVG(udp_conns) AS INTEGER) AS udp_conns, "+
|
||||||
|
"CAST(MAX(uptime) AS INTEGER) AS uptime",
|
||||||
|
bucketExpr,
|
||||||
|
),
|
||||||
|
nodeID,
|
||||||
|
).
|
||||||
|
Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
||||||
|
Group(groupExpr).
|
||||||
|
Order("timestamp ASC").
|
||||||
|
Limit(targetPoints + 100). // safety margin
|
||||||
|
Scan(&metrics).Error
|
||||||
|
|
||||||
|
if metrics == nil {
|
||||||
|
metrics = make([]model.NodeMetric, 0)
|
||||||
}
|
}
|
||||||
return metrics, err
|
return metrics, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1175,7 +1175,7 @@ func TestMetricBatchInsert(t *testing.T) {
|
|||||||
t.Fatalf("batch insert: %v", err)
|
t.Fatalf("batch insert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieved, err := repo.GetNodeMetrics(1, 0, now+1000)
|
retrieved, err := repo.GetNodeMetrics(1, now-10000, now+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ export const getNodeMetrics = (
|
|||||||
return Network.get<NodeMetricApiItem[]>(
|
return Network.get<NodeMetricApiItem[]>(
|
||||||
`/monitor/nodes/${nodeId}/metrics`,
|
`/monitor/nodes/${nodeId}/metrics`,
|
||||||
params,
|
params,
|
||||||
|
{ timeout: 60_000 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@ import type {
|
|||||||
ServiceMonitorLimitsApiData,
|
ServiceMonitorLimitsApiData,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -269,6 +269,143 @@ type MetricType =
|
|||||||
|
|
||||||
const METRICS_MAX_ROWS = 5000;
|
const METRICS_MAX_ROWS = 5000;
|
||||||
|
|
||||||
|
/* ─── Memoized Node Metrics Chart sub-component ─────────────────── */
|
||||||
|
|
||||||
|
interface NodeMetricsChartCardProps {
|
||||||
|
rangeMs: number;
|
||||||
|
onRangeChange: (v: number) => void;
|
||||||
|
activeMetricType: MetricType;
|
||||||
|
onMetricTypeChange: (t: MetricType) => void;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
truncated: boolean;
|
||||||
|
maxRows: number;
|
||||||
|
data: Array<Record<string, unknown>>;
|
||||||
|
nodeId: number | null;
|
||||||
|
onRefresh: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRIC_TYPE_BUTTONS: { key: MetricType; label: string }[] = [
|
||||||
|
{ key: "cpu", label: "CPU" },
|
||||||
|
{ key: "memory", label: "内存" },
|
||||||
|
{ key: "disk", label: "磁盘" },
|
||||||
|
{ key: "network", label: "网络" },
|
||||||
|
{ key: "load", label: "负载" },
|
||||||
|
{ key: "connections", label: "连接" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({
|
||||||
|
rangeMs, onRangeChange, activeMetricType, onMetricTypeChange,
|
||||||
|
loading, error, truncated, maxRows, data, nodeId, onRefresh,
|
||||||
|
}: NodeMetricsChartCardProps) {
|
||||||
|
const chartConfig = (() => {
|
||||||
|
switch (activeMetricType) {
|
||||||
|
case "cpu": return { lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }], yAxisLabel: "使用率 (%)" };
|
||||||
|
case "memory": return { lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }], yAxisLabel: "使用率 (%)" };
|
||||||
|
case "disk": return { lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }], yAxisLabel: "使用率 (%)" };
|
||||||
|
case "network": return { lines: [{ dataKey: "netIn", color: "#10b981", name: "入站速度" }, { dataKey: "netOut", color: "#ef4444", name: "出站速度" }], yAxisLabel: "速度 (bytes/s)" };
|
||||||
|
case "load": return { lines: [{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" }, { dataKey: "load5", color: "#8b5cf6", name: "负载 5m" }, { dataKey: "load15", color: "#f59e0b", name: "负载 15m" }], yAxisLabel: "负载值" };
|
||||||
|
case "connections": return { lines: [{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" }, { dataKey: "udp", color: "#10b981", name: "UDP 连接" }], yAxisLabel: "连接数" };
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const yAxisTickFormatter = (value: unknown) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return "";
|
||||||
|
switch (activeMetricType) {
|
||||||
|
case "network": return formatBytesPerSecond(n);
|
||||||
|
case "cpu": case "memory": case "disk": return `${n.toFixed(0)}%`;
|
||||||
|
case "load": return n.toFixed(1);
|
||||||
|
case "connections": return String(Math.round(n));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltipFormatter = (value: unknown) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return "-";
|
||||||
|
switch (activeMetricType) {
|
||||||
|
case "network": return formatBytesPerSecond(n);
|
||||||
|
case "cpu": case "memory": case "disk": return `${n.toFixed(1)}%`;
|
||||||
|
case "load": return n.toFixed(2);
|
||||||
|
case "connections": return String(Math.round(n));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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(rangeMs)]}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const v = Number(Array.from(keys)[0]);
|
||||||
|
if (v > 0) onRangeChange(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={loading} size="sm" variant="flat" onPress={() => nodeId && onRefresh(nodeId)}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{METRIC_TYPE_BUTTONS.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.key}
|
||||||
|
color={activeMetricType === item.key ? "primary" : "default"}
|
||||||
|
size="sm"
|
||||||
|
variant={activeMetricType === item.key ? "solid" : "flat"}
|
||||||
|
onPress={() => onMetricTypeChange(item.key)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<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" />
|
||||||
|
<XAxis dataKey="time" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} tickFormatter={yAxisTickFormatter} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
formatter={tooltipFormatter}
|
||||||
|
/>
|
||||||
|
{chartConfig.lines.map((line) => (
|
||||||
|
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{truncated && (
|
||||||
|
<div className="text-xs text-default-500">数据点过多,已截断为最近 {maxRows} 条,建议缩小时间范围。</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-default-500">暂无指标数据</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
|
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
|
||||||
checkerScanIntervalSec: 1,
|
checkerScanIntervalSec: 1,
|
||||||
minIntervalSec: 1,
|
minIntervalSec: 1,
|
||||||
@@ -475,8 +612,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMetricsTruncated(false);
|
setMetricsTruncated(false);
|
||||||
setMetricsError(response.msg || "加载指标失败");
|
const msg = response.msg || "加载指标失败";
|
||||||
if (!silent) toast.error(response.msg || "加载指标失败");
|
const isTimeout = msg.toLowerCase().includes("timeout");
|
||||||
|
const friendlyMsg = isTimeout ? "加载指标超时,请缩小时间范围后重试" : msg;
|
||||||
|
setMetricsError(friendlyMsg);
|
||||||
|
if (!silent) toast.error(friendlyMsg);
|
||||||
} catch {
|
} catch {
|
||||||
setMetricsTruncated(false);
|
setMetricsTruncated(false);
|
||||||
if (!silent) setMetricsError("加载指标失败");
|
if (!silent) setMetricsError("加载指标失败");
|
||||||
@@ -629,7 +769,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
void loadServiceMonitors({ silent: true });
|
void loadServiceMonitors({ silent: true });
|
||||||
void loadLatestMonitorResults();
|
void loadLatestMonitorResults();
|
||||||
}, 1_000);
|
}, 5_000);
|
||||||
|
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [loadLatestMonitorResults, loadServiceMonitors]);
|
}, [loadLatestMonitorResults, loadServiceMonitors]);
|
||||||
@@ -660,7 +800,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs });
|
void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs });
|
||||||
}, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]);
|
}, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]);
|
||||||
|
|
||||||
const chartData = metrics.map((m) => ({
|
const chartData = useMemo(() => metrics.map((m) => ({
|
||||||
time: formatTimestamp(m.timestamp, metricsRangeMs),
|
time: formatTimestamp(m.timestamp, metricsRangeMs),
|
||||||
cpu: m.cpuUsage,
|
cpu: m.cpuUsage,
|
||||||
memory: m.memoryUsage,
|
memory: m.memoryUsage,
|
||||||
@@ -672,52 +812,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
load15: m.load15,
|
load15: m.load15,
|
||||||
tcp: m.tcpConns,
|
tcp: m.tcpConns,
|
||||||
udp: m.udpConns,
|
udp: m.udpConns,
|
||||||
}));
|
})), [metrics, metricsRangeMs]);
|
||||||
|
|
||||||
const getChartConfig = () => {
|
|
||||||
switch (activeMetricType) {
|
|
||||||
case "cpu":
|
|
||||||
return {
|
|
||||||
lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }],
|
|
||||||
yAxisLabel: "使用率 (%)",
|
|
||||||
};
|
|
||||||
case "memory":
|
|
||||||
return {
|
|
||||||
lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }],
|
|
||||||
yAxisLabel: "使用率 (%)",
|
|
||||||
};
|
|
||||||
case "disk":
|
|
||||||
return {
|
|
||||||
lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }],
|
|
||||||
yAxisLabel: "使用率 (%)",
|
|
||||||
};
|
|
||||||
case "network":
|
|
||||||
return {
|
|
||||||
lines: [
|
|
||||||
{ dataKey: "netIn", color: "#10b981", name: "入站速度" },
|
|
||||||
{ dataKey: "netOut", color: "#ef4444", name: "出站速度" },
|
|
||||||
],
|
|
||||||
yAxisLabel: "速度 (bytes/s)",
|
|
||||||
};
|
|
||||||
case "load":
|
|
||||||
return {
|
|
||||||
lines: [
|
|
||||||
{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" },
|
|
||||||
{ dataKey: "load5", color: "#8b5cf6", name: "负载 5m" },
|
|
||||||
{ dataKey: "load15", color: "#f59e0b", name: "负载 15m" },
|
|
||||||
],
|
|
||||||
yAxisLabel: "负载值",
|
|
||||||
};
|
|
||||||
case "connections":
|
|
||||||
return {
|
|
||||||
lines: [
|
|
||||||
{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" },
|
|
||||||
{ dataKey: "udp", color: "#10b981", name: "UDP 连接" },
|
|
||||||
],
|
|
||||||
yAxisLabel: "连接数",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -979,46 +1074,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
return { disabled, ok, fail, unknown, stale };
|
return { disabled, ok, fail, unknown, stale };
|
||||||
}, [getLatestResult, isResultStale, serviceMonitors]);
|
}, [getLatestResult, isResultStale, serviceMonitors]);
|
||||||
|
|
||||||
const chartConfig = getChartConfig();
|
|
||||||
|
|
||||||
const nodeYAxisTickFormatter = (value: unknown) => {
|
|
||||||
const n = Number(value);
|
|
||||||
|
|
||||||
if (!Number.isFinite(n)) return "";
|
|
||||||
|
|
||||||
switch (activeMetricType) {
|
|
||||||
case "network":
|
|
||||||
return formatBytesPerSecond(n);
|
|
||||||
case "cpu":
|
|
||||||
case "memory":
|
|
||||||
case "disk":
|
|
||||||
return `${n.toFixed(0)}%`;
|
|
||||||
case "load":
|
|
||||||
return n.toFixed(1);
|
|
||||||
case "connections":
|
|
||||||
return String(Math.round(n));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodeTooltipFormatter = (value: unknown) => {
|
|
||||||
const n = Number(value);
|
|
||||||
|
|
||||||
if (!Number.isFinite(n)) return "-";
|
|
||||||
|
|
||||||
switch (activeMetricType) {
|
|
||||||
case "network":
|
|
||||||
return formatBytesPerSecond(n);
|
|
||||||
case "cpu":
|
|
||||||
case "memory":
|
|
||||||
case "disk":
|
|
||||||
return `${n.toFixed(1)}%`;
|
|
||||||
case "load":
|
|
||||||
return n.toFixed(2);
|
|
||||||
case "connections":
|
|
||||||
return String(Math.round(n));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null;
|
const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null;
|
||||||
@@ -1259,83 +1314,19 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Node metrics chart */}
|
{/* Node metrics chart */}
|
||||||
<Card>
|
<NodeMetricsChartCard
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
rangeMs={metricsRangeMs}
|
||||||
<h3 className="text-lg font-semibold">节点指标图表</h3>
|
onRangeChange={setMetricsRangeMs}
|
||||||
<div className="flex items-center gap-2">
|
activeMetricType={activeMetricType}
|
||||||
<Select
|
onMetricTypeChange={setActiveMetricType}
|
||||||
className="w-36"
|
loading={metricsLoading}
|
||||||
selectedKeys={[String(metricsRangeMs)]}
|
error={metricsError}
|
||||||
onSelectionChange={(keys) => {
|
truncated={metricsTruncated}
|
||||||
const v = Number(Array.from(keys)[0]);
|
maxRows={METRICS_MAX_ROWS}
|
||||||
if (v > 0) setMetricsRangeMs(v);
|
data={chartData}
|
||||||
}}
|
nodeId={selectedNodeId}
|
||||||
>
|
onRefresh={loadMetrics}
|
||||||
<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={metricsLoading} size="sm" variant="flat" onPress={() => selectedNodeId && loadMetrics(selectedNodeId)}>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody className="space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{([
|
|
||||||
{ key: "cpu", label: "CPU" },
|
|
||||||
{ key: "memory", label: "内存" },
|
|
||||||
{ key: "disk", label: "磁盘" },
|
|
||||||
{ key: "network", label: "网络" },
|
|
||||||
{ key: "load", label: "负载" },
|
|
||||||
{ key: "connections", label: "连接" },
|
|
||||||
] as { key: MetricType; label: string }[]).map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.key}
|
|
||||||
color={activeMetricType === item.key ? "primary" : "default"}
|
|
||||||
size="sm"
|
|
||||||
variant={activeMetricType === item.key ? "solid" : "flat"}
|
|
||||||
onPress={() => setActiveMetricType(item.key)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{metricsLoading ? (
|
|
||||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
|
||||||
) : metricsError ? (
|
|
||||||
<div className="text-center py-8 text-danger text-sm">{metricsError}</div>
|
|
||||||
) : metrics.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="h-64">
|
|
||||||
<ResponsiveContainer height="100%" width="100%">
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="time" fontSize={12} />
|
|
||||||
<YAxis fontSize={12} tickFormatter={nodeYAxisTickFormatter} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
|
||||||
labelStyle={{ color: "#fff" }}
|
|
||||||
formatter={nodeTooltipFormatter}
|
|
||||||
/>
|
|
||||||
{chartConfig.lines.map((line) => (
|
|
||||||
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
{metricsTruncated && (
|
|
||||||
<div className="text-xs text-default-500">数据点过多,已截断为最近 {METRICS_MAX_ROWS} 条,建议缩小时间范围。</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-default-500">暂无指标数据</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Service monitors chart – same style as node metrics */}
|
{/* Service monitors chart – same style as node metrics */}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
TunnelQualityApiItem,
|
TunnelQualityApiItem,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -72,15 +72,6 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Render a colored latency value with appropriate visual cue */
|
/** Render a colored latency value with appropriate visual cue */
|
||||||
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
|
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
|
||||||
@@ -182,6 +173,161 @@ function UptimeHistoryBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Module-level constants & memoized sub-components ────────────── */
|
||||||
|
|
||||||
|
const TIME_RANGE_OPTIONS = [
|
||||||
|
{ key: String(15 * 60 * 1000), label: "15分钟" },
|
||||||
|
{ key: String(60 * 60 * 1000), label: "1小时" },
|
||||||
|
{ key: String(6 * 60 * 60 * 1000), label: "6小时" },
|
||||||
|
{ key: String(24 * 60 * 60 * 1000), label: "24小时" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TIME_RANGE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.key}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QualityChartCardProps {
|
||||||
|
rangeMs: number;
|
||||||
|
onRangeChange: (v: number) => void;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | 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,
|
||||||
|
}: QualityChartCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<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)}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{loading ? (
|
||||||
|
<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} />
|
||||||
|
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
||||||
|
<YAxis
|
||||||
|
fontSize={11}
|
||||||
|
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" }}
|
||||||
|
formatter={(value: unknown, name: string) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return "-";
|
||||||
|
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
|
||||||
|
return [`${n.toFixed(1)}ms`, label];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TrafficChartCardProps {
|
||||||
|
rangeMs: number;
|
||||||
|
onRangeChange: (v: number) => void;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
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,
|
||||||
|
}: 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]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<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)}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{loading ? (
|
||||||
|
<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} />
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
|
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
|
||||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
||||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||||
@@ -419,34 +565,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
|
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
|
||||||
|
|
||||||
// Chart data for quality history
|
// Memoize chart data so React.memo sub-components see stable references
|
||||||
const qualityChartData = qualityHistory.map((q) => ({
|
const qualityChartData = useMemo(
|
||||||
time: formatTimestamp(q.timestamp, qualityRangeMs),
|
() => qualityHistory.map((q) => ({
|
||||||
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
|
time: formatTimestamp(q.timestamp, qualityRangeMs),
|
||||||
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
|
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
|
||||||
entryToExitLoss: q.entryToExitLoss,
|
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
|
||||||
exitToBingLoss: q.exitToBingLoss,
|
entryToExitLoss: q.entryToExitLoss,
|
||||||
}));
|
exitToBingLoss: q.exitToBingLoss,
|
||||||
|
})),
|
||||||
|
[qualityHistory, qualityRangeMs],
|
||||||
|
);
|
||||||
|
|
||||||
// Chart data for traffic metrics
|
const tunnelChartData = useMemo(
|
||||||
const tunnelChartData = tunnelMetrics.map((m) => ({
|
() => tunnelMetrics.map((m) => ({
|
||||||
time: formatTimestamp(m.timestamp, tunnelRangeMs),
|
time: formatTimestamp(m.timestamp, tunnelRangeMs),
|
||||||
bytesIn: m.bytesIn,
|
bytesIn: m.bytesIn,
|
||||||
bytesOut: m.bytesOut,
|
bytesOut: m.bytesOut,
|
||||||
connections: m.connections,
|
connections: m.connections,
|
||||||
}));
|
})),
|
||||||
|
[tunnelMetrics, tunnelRangeMs],
|
||||||
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
|
const detailTunnel = detailTunnelId != null
|
||||||
? tunnels.find((t) => t.id === detailTunnelId)
|
? tunnels.find((t) => t.id === detailTunnelId)
|
||||||
@@ -468,23 +607,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
|
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
|
||||||
}, [qualityMap]);
|
}, [qualityMap]);
|
||||||
|
|
||||||
/** Shared time range Select component */
|
|
||||||
const TimeRangeSelect = ({ value, onChange }: { value: number; onChange: (v: number) => void }) => (
|
|
||||||
<Select
|
|
||||||
className="w-36"
|
|
||||||
selectedKeys={[String(value)]}
|
|
||||||
onSelectionChange={(keys) => {
|
|
||||||
const v = Number(Array.from(keys)[0]);
|
|
||||||
if (v > 0) onChange(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>
|
|
||||||
);
|
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// RENDER
|
// RENDER
|
||||||
// =====================
|
// =====================
|
||||||
@@ -583,123 +705,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ====== Quality History Chart (mirrors service monitor chart) ====== */}
|
{/* ====== Quality History Chart — isolated with React.memo ====== */}
|
||||||
<Card>
|
<QualityChartCard
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
rangeMs={qualityRangeMs}
|
||||||
<h3 className="text-lg font-semibold">质量趋势</h3>
|
onRangeChange={setQualityRangeMs}
|
||||||
<div className="flex items-center gap-2">
|
loading={qualityHistoryLoading}
|
||||||
<TimeRangeSelect value={qualityRangeMs} onChange={setQualityRangeMs} />
|
error={qualityHistoryError}
|
||||||
<Button
|
data={qualityChartData}
|
||||||
isLoading={qualityHistoryLoading}
|
tunnelId={detailTunnelId}
|
||||||
size="sm"
|
onRefresh={loadQualityHistory}
|
||||||
variant="flat"
|
/>
|
||||||
onPress={() => detailTunnelId && loadQualityHistory(detailTunnelId)}
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
{qualityHistoryLoading ? (
|
|
||||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
|
||||||
) : qualityHistoryError ? (
|
|
||||||
<div className="text-center py-8 text-danger text-sm">{qualityHistoryError}</div>
|
|
||||||
) : qualityChartData.length > 0 ? (
|
|
||||||
<div className="h-64">
|
|
||||||
<ResponsiveContainer height="100%" width="100%">
|
|
||||||
<LineChart data={qualityChartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
|
||||||
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
|
||||||
<YAxis
|
|
||||||
fontSize={11}
|
|
||||||
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" }}
|
|
||||||
formatter={(value: unknown, name: string) => {
|
|
||||||
const n = Number(value);
|
|
||||||
if (!Number.isFinite(n)) return "-";
|
|
||||||
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
|
|
||||||
return [`${n.toFixed(1)}ms`, label];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Line
|
{/* ====== Traffic Chart — isolated with React.memo ====== */}
|
||||||
connectNulls
|
<TrafficChartCard
|
||||||
dataKey="entryToExit"
|
rangeMs={tunnelRangeMs}
|
||||||
dot={false}
|
onRangeChange={setTunnelRangeMs}
|
||||||
name="entryToExit"
|
loading={tunnelMetricsLoading}
|
||||||
stroke="#10b981"
|
error={tunnelMetricsError}
|
||||||
strokeWidth={2}
|
data={tunnelChartData}
|
||||||
type="monotone"
|
tunnelId={detailTunnelId}
|
||||||
/>
|
onRefresh={loadTunnelMetrics}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ====== Traffic Chart (unchanged) ====== */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold">流量趋势</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TimeRangeSelect value={tunnelRangeMs} onChange={setTunnelRangeMs} />
|
|
||||||
<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>
|
|
||||||
) : tunnelChartData.length > 0 ? (
|
|
||||||
<div className="h-64">
|
|
||||||
<ResponsiveContainer height="100%" width="100%">
|
|
||||||
<LineChart data={tunnelChartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
|
||||||
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
|
||||||
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={tunnelYAxisTickFormatter} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
|
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user