From 45065178b81c182200d81ed76c3179f85f588f40 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 22 Mar 2026 13:04:43 +0800 Subject: [PATCH 1/3] chore: update AGENTS.md for release 2.1.9-beta14 --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c86fc52..5a5a48d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ # PROJECT KNOWLEDGE BASE -**Generated:** Sat Mar 21 2026 -**Commit:** 0767c9c +**Generated:** Sun Mar 22 2026 +**Commit:** 8ebde9d **Branch:** main -**Tag:** 2.1.9-beta13 +**Tag:** 2.1.9-beta14 ## OVERVIEW FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers. From aafdb784829d2ffe754c49ff7607da69d5e1c192 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 22 Mar 2026 13:33:37 +0800 Subject: [PATCH 2/3] feat: node logo by distro types and official icons --- vite-frontend/src/components/distro-icon.tsx | 42 +++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/vite-frontend/src/components/distro-icon.tsx b/vite-frontend/src/components/distro-icon.tsx index de0d151..3936218 100644 --- a/vite-frontend/src/components/distro-icon.tsx +++ b/vite-frontend/src/components/distro-icon.tsx @@ -1,7 +1,9 @@ import type { SVGProps } from "react"; -// Map of Linux distribution names to their SVG logo components. -// Version strings from the agent look like "v1.0.0 (ubuntu/amd64)". +/** + * Linux distribution icons using official SVG paths from simple-icons. + * Version strings from the agent look like "v1.0.0 (ubuntu/amd64)". + */ const distroColors: Record = { ubuntu: "#E95420", @@ -22,19 +24,15 @@ const distroColors: Record = { kali: "#557C94", mint: "#87CF3E", gentoo: "#54487A", - void: "#478061", - nixos: "#7EBAE4", manjaro: "#35BF5C", }; +/* ---------- Official SVG logo components (simple-icons paths) ---------- */ + function UbuntuIcon(props: SVGProps) { return ( - - - - - + ); } @@ -42,7 +40,7 @@ function UbuntuIcon(props: SVGProps) { function DebianIcon(props: SVGProps) { return ( - + ); } @@ -50,11 +48,7 @@ function DebianIcon(props: SVGProps) { function CentOSIcon(props: SVGProps) { return ( - - - - - + ); } @@ -62,7 +56,7 @@ function CentOSIcon(props: SVGProps) { function AlpineIcon(props: SVGProps) { return ( - + ); } @@ -70,9 +64,7 @@ function AlpineIcon(props: SVGProps) { function FedoraIcon(props: SVGProps) { return ( - - - f + ); } @@ -80,20 +72,22 @@ function FedoraIcon(props: SVGProps) { function ArchIcon(props: SVGProps) { return ( - + ); } -function DefaultLinuxIcon(props: SVGProps) { - // Tux-inspired simple penguin outline +function LinuxIcon(props: SVGProps) { + // Official Tux (Linux penguin) logo from simple-icons return ( - + ); } +/* ---------- Exports ---------- */ + type DistroIconProps = SVGProps & { distro: string; }; @@ -150,5 +144,5 @@ export function DistroIcon({ distro, ...props }: DistroIconProps) { if (key.includes("fedora")) return ; if (key.includes("arch") || key.includes("manjaro")) return ; - return ; + return ; } From 3826cb02c058a0f076aba4d6f15b4bd1b1e9942f Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 22 Mar 2026 17:55:10 +0800 Subject: [PATCH 3/3] chore: update monitor rendering and release rc2 --- go-backend/internal/metrics/ingestion_test.go | 14 +- go-backend/internal/store/repo/repository.go | 62 ++- .../contract/monitoring_contract_test.go | 2 +- vite-frontend/src/api/index.ts | 1 + vite-frontend/src/pages/node/monitor-view.tsx | 327 ++++++++-------- .../src/pages/node/tunnel-monitor-view.tsx | 366 ++++++++++-------- 6 files changed, 418 insertions(+), 354 deletions(-) diff --git a/go-backend/internal/metrics/ingestion_test.go b/go-backend/internal/metrics/ingestion_test.go index 3d5f679..482a04c 100644 --- a/go-backend/internal/metrics/ingestion_test.go +++ b/go-backend/internal/metrics/ingestion_test.go @@ -36,7 +36,7 @@ func TestRecordNodeMetric(t *testing.T) { svc.RecordNodeMetric(1, info) 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 { t.Fatalf("get metrics: %v", err) } @@ -86,7 +86,7 @@ func TestRecordNodeMetricAutoFlush(t *testing.T) { 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 { t.Fatalf("get metrics: %v", err) } @@ -123,7 +123,7 @@ func TestIngestionServiceStart(t *testing.T) { <-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 { t.Fatalf("get metrics: %v", err) } @@ -198,7 +198,7 @@ func TestGetMetricsWithTimeRange(t *testing.T) { svc.flushNodeMetrics() - metrics, err := svc.GetMetrics(1, 0, now+1000) + metrics, err := svc.GetMetrics(1, now-60000, now+1000) if err != nil { t.Fatalf("get metrics: %v", err) } @@ -224,7 +224,7 @@ func TestPruneMetrics(t *testing.T) { 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 { t.Fatalf("get metrics: %v", err) } @@ -255,7 +255,7 @@ func TestMultipleNodes(t *testing.T) { svc.flushNodeMetrics() 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 { t.Fatalf("get metrics for node %d: %v", nodeID, err) } @@ -279,7 +279,7 @@ func TestZeroValues(t *testing.T) { svc.RecordNodeMetric(1, info) 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 { t.Fatalf("get metrics: %v", err) } diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index a6f7848..8c7ca4d 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -3320,15 +3320,61 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model if r == nil || r.db == 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 - err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs). - Order("timestamp DESC"). - Limit(5000). - Find(&metrics).Error - if len(metrics) > 1 { - for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 { - metrics[i], metrics[j] = metrics[j], metrics[i] - } + err := r.db.Model(&model.NodeMetric{}). + Select( + fmt.Sprintf( + "? AS node_id, "+ + "CAST(%s AS INTEGER) AS timestamp, "+ + "AVG(cpu_usage) AS cpu_usage, "+ + "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 } diff --git a/go-backend/tests/contract/monitoring_contract_test.go b/go-backend/tests/contract/monitoring_contract_test.go index 8dd3b4d..584203d 100644 --- a/go-backend/tests/contract/monitoring_contract_test.go +++ b/go-backend/tests/contract/monitoring_contract_test.go @@ -1175,7 +1175,7 @@ func TestMetricBatchInsert(t *testing.T) { 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 { t.Fatalf("get metrics: %v", err) } diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 8e00a47..3deed23 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -427,6 +427,7 @@ export const getNodeMetrics = ( return Network.get( `/monitor/nodes/${nodeId}/metrics`, params, + { timeout: 60_000 }, ); }; diff --git a/vite-frontend/src/pages/node/monitor-view.tsx b/vite-frontend/src/pages/node/monitor-view.tsx index 5928be3..88cd7fe 100644 --- a/vite-frontend/src/pages/node/monitor-view.tsx +++ b/vite-frontend/src/pages/node/monitor-view.tsx @@ -6,7 +6,7 @@ import type { ServiceMonitorLimitsApiData, } from "@/api/types"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { LineChart, Line, @@ -269,6 +269,143 @@ type MetricType = 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>; + 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 ( + + +

节点指标图表

+
+ + +
+
+ +
+ {METRIC_TYPE_BUTTONS.map((item) => ( + + ))} +
+ + {loading ? ( +
+ ) : error ? ( +
{error}
+ ) : data.length > 0 ? ( + <> +
+ + + + + + + {chartConfig.lines.map((line) => ( + + ))} + + +
+ {truncated && ( +
数据点过多,已截断为最近 {maxRows} 条,建议缩小时间范围。
+ )} + + ) : ( +
暂无指标数据
+ )} +
+
+ ); +}); + + const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = { checkerScanIntervalSec: 1, minIntervalSec: 1, @@ -475,8 +612,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { return; } setMetricsTruncated(false); - setMetricsError(response.msg || "加载指标失败"); - if (!silent) toast.error(response.msg || "加载指标失败"); + const msg = response.msg || "加载指标失败"; + const isTimeout = msg.toLowerCase().includes("timeout"); + const friendlyMsg = isTimeout ? "加载指标超时,请缩小时间范围后重试" : msg; + setMetricsError(friendlyMsg); + if (!silent) toast.error(friendlyMsg); } catch { setMetricsTruncated(false); if (!silent) setMetricsError("加载指标失败"); @@ -629,7 +769,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { const timer = window.setInterval(() => { void loadServiceMonitors({ silent: true }); void loadLatestMonitorResults(); - }, 1_000); + }, 5_000); return () => window.clearInterval(timer); }, [loadLatestMonitorResults, loadServiceMonitors]); @@ -660,7 +800,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs }); }, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]); - const chartData = metrics.map((m) => ({ + const chartData = useMemo(() => metrics.map((m) => ({ time: formatTimestamp(m.timestamp, metricsRangeMs), cpu: m.cpuUsage, memory: m.memoryUsage, @@ -672,52 +812,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { load15: m.load15, tcp: m.tcpConns, udp: m.udpConns, - })); - - 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: "连接数", - }; - } - }; + })), [metrics, metricsRangeMs]); @@ -979,46 +1074,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { return { disabled, ok, fail, unknown, stale }; }, [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; @@ -1259,83 +1314,19 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { )} {/* Node metrics chart */} - - -

节点指标图表

-
- - -
-
- -
- {([ - { 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) => ( - - ))} -
- - {metricsLoading ? ( -
- ) : metricsError ? ( -
{metricsError}
- ) : metrics.length > 0 ? ( - <> -
- - - - - - - {chartConfig.lines.map((line) => ( - - ))} - - -
- {metricsTruncated && ( -
数据点过多,已截断为最近 {METRICS_MAX_ROWS} 条,建议缩小时间范围。
- )} - - ) : ( -
暂无指标数据
- )} -
-
+ {/* Service monitors chart – same style as node metrics */} diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx index 1568f13..eebd329 100644 --- a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx +++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx @@ -4,7 +4,7 @@ import type { TunnelQualityApiItem, } from "@/api/types"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LineChart, 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 */ 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 ( + + ); +} + +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 ( + + +

质量趋势

+
+ + +
+
+ + {loading ? ( +
+ ) : error ? ( +
{error}
+ ) : data.length > 0 ? ( +
+ + + + + `${Number(v).toFixed(0)}ms`} + label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }} + /> + { + const n = Number(value); + if (!Number.isFinite(n)) return "-"; + const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name; + return [`${n.toFixed(1)}ms`, label]; + }} + /> + + + + +
+ ) : ( +
暂无质量历史数据
+ )} +
+
+ ); +}); + +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 ( + + +

流量趋势

+
+ + +
+
+ + {loading ? ( +
+ ) : error ? ( +
{error}
+ ) : data.length > 0 ? ( +
+ + + + + + + + + + +
+ ) : ( +
暂无流量数据
+ )} +
+
+ ); +}); + export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) { const [tunnels, setTunnels] = useState([]); const [tunnelsLoading, setTunnelsLoading] = useState(false); @@ -419,34 +565,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) return () => window.clearInterval(timer); }, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]); - // Chart data for quality history - const qualityChartData = 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, - })); + // 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, qualityRangeMs], + ); - // Chart data for traffic metrics - const tunnelChartData = tunnelMetrics.map((m) => ({ - time: formatTimestamp(m.timestamp, tunnelRangeMs), - bytesIn: m.bytesIn, - bytesOut: m.bytesOut, - connections: m.connections, - })); - - 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 tunnelChartData = useMemo( + () => 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) @@ -468,23 +607,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null; }, [qualityMap]); - /** Shared time range Select component */ - const TimeRangeSelect = ({ value, onChange }: { value: number; onChange: (v: number) => void }) => ( - - ); - // ===================== // RENDER // ===================== @@ -583,123 +705,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) )} - {/* ====== Quality History Chart (mirrors service monitor chart) ====== */} - - -

质量趋势

-
- - -
-
- - {qualityHistoryLoading ? ( -
- ) : qualityHistoryError ? ( -
{qualityHistoryError}
- ) : qualityChartData.length > 0 ? ( -
- - - - - `${Number(v).toFixed(0)}ms`} - label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }} - /> - { - const n = Number(value); - if (!Number.isFinite(n)) return "-"; - const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name; - return [`${n.toFixed(1)}ms`, label]; - }} - /> + {/* ====== Quality History Chart — isolated with React.memo ====== */} + - - - - -
- ) : ( -
暂无质量历史数据
- )} -
-
- - {/* ====== Traffic Chart (unchanged) ====== */} - - -

流量趋势

-
- - -
-
- - {tunnelMetricsLoading ? ( -
- ) : tunnelMetricsError ? ( -
{tunnelMetricsError}
- ) : tunnelChartData.length > 0 ? ( -
- - - - - - - - - - - -
- ) : ( -
暂无流量数据
- )} -
-
+ {/* ====== Traffic Chart — isolated with React.memo ====== */} + ); }