mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdcc30a493 | |||
| efaffb0475 | |||
| 4954526cbc | |||
| 9d50071915 | |||
| ceceee6ebd | |||
| 11051f5517 | |||
| ff2c7c4959 | |||
| 6364b96935 | |||
| 409f0a232a | |||
| f79994e0e0 | |||
| 9fdb16d035 | |||
| 53b632a6f7 | |||
| bf7b2a0740 | |||
| 8475bc27bb | |||
| 5d01572eff | |||
| a353faaa71 | |||
| e7b25004ba | |||
| 3826cb02c0 | |||
| a1fee8e432 | |||
| aafdb78482 | |||
| 16b545d8cd | |||
| 45065178b8 | |||
| 8ebde9dca9 | |||
| 0a1ec60750 | |||
| 9ec35d2f2f | |||
| c914040b7d | |||
| 822362c44c | |||
| 80f5935b76 | |||
| 433c8aab13 | |||
| 949dfcd42d | |||
| 1580e4ee10 | |||
| 32e4f0f514 |
@@ -1,9 +1,9 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Sat Mar 21 2026
|
||||
**Commit:** ac35068
|
||||
**Generated:** Tue Mar 24 2026
|
||||
**Commit:** 8ebde9d
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta10
|
||||
**Tag:** 2.1.9-rc8
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -71,6 +71,7 @@ type monitorNodeListItem struct {
|
||||
Inx int `json:"inx"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
Version string `json:"version"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
}
|
||||
|
||||
@@ -100,6 +101,7 @@ func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request)
|
||||
Inx: n.Inx,
|
||||
Name: n.Name,
|
||||
Status: n.Status,
|
||||
Version: n.Version.String,
|
||||
UpdatedTime: updated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1102,24 +1102,113 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port := pickForwardPortFromRecords(oldPorts)
|
||||
if port <= 0 {
|
||||
referencePort := pickForwardPortFromRecords(oldPorts)
|
||||
if referencePort <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var entries []forwardPortReplaceEntry
|
||||
if allowInIP {
|
||||
entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port)
|
||||
} else {
|
||||
entries = make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
|
||||
for _, nid := range entryNodeIDs {
|
||||
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: ""})
|
||||
// Build a map of existing node → port/inIP from old records.
|
||||
oldPortByNode := make(map[int64]forwardPortRecord)
|
||||
for _, fp := range oldPorts {
|
||||
if fp.NodeID > 0 {
|
||||
oldPortByNode[fp.NodeID] = fp
|
||||
}
|
||||
}
|
||||
|
||||
entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
|
||||
for _, nid := range entryNodeIDs {
|
||||
if existing, ok := oldPortByNode[nid]; ok && existing.Port > 0 {
|
||||
// Existing entry node: keep its current port.
|
||||
inIP := existing.InIP
|
||||
if !allowInIP {
|
||||
inIP = ""
|
||||
}
|
||||
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: existing.Port, InIP: inIP})
|
||||
continue
|
||||
}
|
||||
|
||||
// New entry node: try to follow the reference port.
|
||||
port := h.resolvePortForNewEntryNode(nid, referencePort, f.ID)
|
||||
inIP := ""
|
||||
if allowInIP {
|
||||
// For single-entry tunnels, try to preserve inIP from old records.
|
||||
for _, fp := range oldPorts {
|
||||
if strings.TrimSpace(fp.InIP) != "" {
|
||||
inIP = fp.InIP
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIP})
|
||||
}
|
||||
_ = h.repo.ReplaceForwardPorts(f.ID, entries)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePortForNewEntryNode determines the port for a forward on a newly added
|
||||
// entry node. It tries to reuse referencePort (from existing entries); if that
|
||||
// port is out of range or already occupied, it picks a random available port
|
||||
// for this specific node.
|
||||
func (h *Handler) resolvePortForNewEntryNode(nodeID int64, referencePort int, forwardID int64) int {
|
||||
node, err := h.getNodeRecord(nodeID)
|
||||
if err != nil {
|
||||
return referencePort
|
||||
}
|
||||
|
||||
// Check if referencePort is within the node's allowed range.
|
||||
if validateLocalNodePort(node, referencePort) == nil &&
|
||||
validateRemoteNodePort(node, referencePort) == nil {
|
||||
// In range — check availability.
|
||||
occupied, occErr := h.repo.HasOtherForwardOnNodePort(nodeID, referencePort, forwardID)
|
||||
if occErr == nil && !occupied {
|
||||
return referencePort
|
||||
}
|
||||
}
|
||||
|
||||
// referencePort doesn't work for this node; pick a random one.
|
||||
newPort := h.pickRandomPortForNode(nodeID)
|
||||
if newPort > 0 {
|
||||
return newPort
|
||||
}
|
||||
return referencePort // last resort fallback
|
||||
}
|
||||
|
||||
// pickRandomPortForNode picks a random available port from a single node's
|
||||
// port range, excluding ports already occupied by other forwards or chains.
|
||||
func (h *Handler) pickRandomPortForNode(nodeID int64) int {
|
||||
portRange, err := h.repo.GetNodePortRange(nodeID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if portRange == "" {
|
||||
portRange = "1000-65535"
|
||||
}
|
||||
|
||||
nodePorts, err := parsePorts(portRange)
|
||||
if err != nil || len(nodePorts) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
used, err := h.getUsedPorts(nodeID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var available []int
|
||||
for _, p := range nodePorts {
|
||||
if !used[p] {
|
||||
available = append(available, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(available) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(available))))
|
||||
return available[idx.Int64()]
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
|
||||
@@ -35,6 +35,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
}
|
||||
|
||||
forwardDeltas := make(map[int64]tunnelTrafficDelta)
|
||||
var skippedParse, skippedZero int
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.N)
|
||||
if name == "" || name == "web_api" {
|
||||
@@ -42,9 +43,11 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
}
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(name)
|
||||
if !ok {
|
||||
skippedParse++
|
||||
continue
|
||||
}
|
||||
if item.D == 0 && item.U == 0 {
|
||||
skippedZero++
|
||||
continue
|
||||
}
|
||||
d := forwardDeltas[forwardID]
|
||||
@@ -53,6 +56,9 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
forwardDeltas[forwardID] = d
|
||||
}
|
||||
if len(forwardDeltas) == 0 {
|
||||
if len(items) > 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_forward_deltas node_id=%d items=%d skipped_parse=%d skipped_zero=%d", nodeID, len(items), skippedParse, skippedZero)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,6 +73,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
return
|
||||
}
|
||||
if len(forwardTunnelMap) == 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_tunnel_map node_id=%d forward_ids=%v", nodeID, forwardIDs)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,5 +114,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
|
||||
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
|
||||
log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err)
|
||||
} else {
|
||||
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3320,15 +3320,60 @@ 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(
|
||||
"%d 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",
|
||||
nodeID, bucketExpr,
|
||||
),
|
||||
).
|
||||
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
|
||||
}
|
||||
@@ -3422,10 +3467,10 @@ func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) er
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"bytes_in": gorm.Expr("bytes_in + excluded.bytes_in"),
|
||||
"bytes_out": gorm.Expr("bytes_out + excluded.bytes_out"),
|
||||
"connections": gorm.Expr("connections + excluded.connections"),
|
||||
"errors": gorm.Expr("errors + excluded.errors"),
|
||||
"bytes_in": gorm.Expr("tunnel_metric.bytes_in + excluded.bytes_in"),
|
||||
"bytes_out": gorm.Expr("tunnel_metric.bytes_out + excluded.bytes_out"),
|
||||
"connections": gorm.Expr("tunnel_metric.connections + excluded.connections"),
|
||||
"errors": gorm.Expr("tunnel_metric.errors + excluded.errors"),
|
||||
// avg_latency_ms is not additive; keep the existing bucket value.
|
||||
}),
|
||||
}).CreateInBatches(rows, 100).Error
|
||||
|
||||
@@ -11,7 +11,7 @@ func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var nodes []model.Node
|
||||
err := r.db.Select("id", "inx", "name", "status", "updated_time").
|
||||
err := r.db.Select("id", "inx", "name", "status", "version", "updated_time").
|
||||
Where("is_remote = ?", 0).
|
||||
Order("inx ASC, id ASC").
|
||||
Find(&nodes).Error
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+3
-1
@@ -119,7 +119,9 @@ func main() {
|
||||
log := xlogger.NewLogger()
|
||||
logger.SetDefault(log)
|
||||
|
||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
|
||||
distro := socket.DetectDistro()
|
||||
fullVersion := fmt.Sprintf("%s (%s/%s)", version, distro, runtime.GOARCH)
|
||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
|
||||
defer wsReporter.Stop()
|
||||
service.SetHTTPReportURL(config.Addr, config.Secret)
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
)
|
||||
|
||||
// DetectDistro returns the Linux distribution name (e.g. "ubuntu", "centos",
|
||||
// "debian"). Falls back to "linux" when detection fails.
|
||||
func DetectDistro() string {
|
||||
info, err := host.Info()
|
||||
if err != nil || info == nil {
|
||||
return "linux"
|
||||
}
|
||||
platform := strings.ToLower(strings.TrimSpace(info.Platform))
|
||||
if platform == "" {
|
||||
return "linux"
|
||||
}
|
||||
return platform
|
||||
}
|
||||
@@ -348,7 +348,7 @@ func buildWebSocketCandidates(addr string, secret string, version string, http i
|
||||
normalizedAddr = strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
|
||||
query := "/system-info?type=1&secret=" + url.QueryEscape(secret) + "&version=" + url.QueryEscape(version) +
|
||||
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
||||
|
||||
schemes := []string{"wss", "ws"}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 013 - 隧道入口端口校验不严格修复
|
||||
|
||||
## Issue
|
||||
- GitHub Issue: [#373](https://github.com/Sagit-chu/flvx/issues/373)
|
||||
|
||||
## 修复方案
|
||||
|
||||
在 `syncTunnelForwardsEntryPorts` 中实现逐节点端口分配:
|
||||
|
||||
- **旧入口节点**:保留原端口不变
|
||||
- **新入口节点**:通过 `resolvePortForNewEntryNode` 决策:
|
||||
- 参考端口在范围内且未被占用 → 跟随设置一样的端口
|
||||
- 参考端口超出范围或被占用 → 通过 `pickRandomPortForNode` 为该节点单独随机分配
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 1. 实现 `pickRandomPortForNode` 辅助方法(单节点端口随机分配)
|
||||
- [x] 2. 实现 `resolvePortForNewEntryNode` 方法(端口决策逻辑)
|
||||
- [x] 3. 重写 `syncTunnelForwardsEntryPorts` 为逐节点分配
|
||||
- [x] 4. 移除不再需要的 `isPortValidForAllEntryNodes`
|
||||
- [x] 5. 构建通过 + 全量测试通过
|
||||
@@ -0,0 +1,15 @@
|
||||
# Plan 061: Node Logo By OS Type (Linux Distro)
|
||||
|
||||
## Objective
|
||||
Display different logos for nodes in the 'Monitor - Node - Card/List View' based on their Linux distribution (Ubuntu, Debian, CentOS, Alpine, etc.).
|
||||
|
||||
## Tasks
|
||||
- [x] Agent: Use `gopsutil/v3/host.Info().Platform` to detect the Linux distro and include it in the version string (`distro.go`).
|
||||
- [x] Agent: Update `main.go` to call `socket.DetectDistro()` instead of `runtime.GOOS`.
|
||||
- [x] Agent: Ensure version parameter is URL-escaped since it now includes distro info with special chars.
|
||||
- [x] Backend: Select `version` column in `ListMonitorNodes`.
|
||||
- [x] Backend: Include `version` in `monitorNodeListItem` JSON response.
|
||||
- [x] Frontend: Add `version` to TypeScript interfaces (`MonitorNodeApiItem`, `MonitorNode`, `MonitorViewProps`).
|
||||
- [x] Frontend: Create `distro-icon.tsx` component with SVG logos for Ubuntu, Debian, CentOS/Rocky/Alma, Alpine, Fedora, Arch/Manjaro, and a default Linux (Tux) fallback.
|
||||
- [x] Frontend: Use `DistroIcon` in `ServerCard` (card view) and list view name column with branded colors per distro.
|
||||
- [x] All three projects compile cleanly (`go build`, `tsc --noEmit`).
|
||||
@@ -0,0 +1,49 @@
|
||||
# Plan 062: Commit, PR, Merge and Tag
|
||||
|
||||
## Overview
|
||||
This plan outlines the steps to commit all changes, create a PR, merge it, and then publish a new tag for the FLVX project.
|
||||
|
||||
## Checklist
|
||||
- [ ] Check current git status for any unexpected changes
|
||||
- [ ] Create a feature branch `feat-node-os-logo-release`
|
||||
- [ ] Stage and commit all modifications and untracked files
|
||||
- [ ] Push the feature branch to origin
|
||||
- [ ] Create a Pull Request (PR) from the feature branch to `main`
|
||||
- [ ] Merge the PR to `main`
|
||||
- [ ] Update `AGENTS.md` with the new tag and commit hash
|
||||
- [ ] Create and push new tag `2.1.9-beta14`
|
||||
|
||||
## Detailed Steps
|
||||
|
||||
### 1. Create Feature Branch
|
||||
```bash
|
||||
git checkout -b feat-node-os-logo-release
|
||||
```
|
||||
|
||||
### 2. Commit all changes
|
||||
Add all modified and untracked files:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: node OS logo support, UI rate overlap fix and tunnel monitoring updates"
|
||||
```
|
||||
|
||||
### 3. Push and PR
|
||||
Push to `origin`:
|
||||
```bash
|
||||
git push origin feat-node-os-logo-release
|
||||
```
|
||||
Create PR via `gh pr create` if possible.
|
||||
|
||||
### 4. Merge to Main
|
||||
```bash
|
||||
git checkout main
|
||||
git merge feat-node-os-logo-release
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 5. Create Tag
|
||||
Increment the current tag `2.1.9-beta13` to `2.1.9-beta14`.
|
||||
```bash
|
||||
git tag 2.1.9-beta14
|
||||
git push origin 2.1.9-beta14
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
# Plan 063: Release 2.1.9-rc6
|
||||
|
||||
Sync all changes, bump version to `2.1.9-rc6`, create PR, merge, and publish tag.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc6`) and today's date (`Tue Mar 24 2026`).
|
||||
- [x] Commit all changes to branch `chore/rc6-bump`.
|
||||
- [x] Push branch to remote.
|
||||
- [x] Create Pull Request using `gh`.
|
||||
- [x] Merge Pull Request using `gh`.
|
||||
- [x] Create and push tag `2.1.9-rc6`.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Plan 064: Release 2.1.9-rc7
|
||||
|
||||
Sync all changes, bump version to `2.1.9-rc7`, create PR, merge, and publish tag.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc7`) and today's date (`Tue Mar 24 2026`).
|
||||
- [x] Commit all changes to branch `chore/rc7-bump`.
|
||||
- [x] Push branch to remote.
|
||||
- [x] Create Pull Request using `gh`.
|
||||
- [x] Merge Pull Request using `gh`.
|
||||
- [x] Create and push tag `2.1.9-rc7`.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@flvx/skill-api",
|
||||
"version": "2.1.5",
|
||||
"version": "2.1.9-rc4",
|
||||
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
|
||||
"keywords": [
|
||||
"opencode",
|
||||
|
||||
@@ -427,6 +427,7 @@ export const getNodeMetrics = (
|
||||
return Network.get<NodeMetricApiItem[]>(
|
||||
`/monitor/nodes/${nodeId}/metrics`,
|
||||
params,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -466,6 +466,7 @@ export interface MonitorNodeApiItem {
|
||||
inx: number;
|
||||
name: string;
|
||||
status: number;
|
||||
version?: string;
|
||||
updatedTime: number;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,6 +15,7 @@ type MonitorNode = {
|
||||
id: number;
|
||||
name: string;
|
||||
connectionStatus: "online" | "offline";
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type MonitorTab = "nodes" | "tunnels";
|
||||
@@ -73,6 +74,7 @@ export default function MonitorPage() {
|
||||
id: Number(n.id),
|
||||
name: String(n.name ?? ""),
|
||||
connectionStatus: n.status === 1 ? "online" : "offline",
|
||||
version: n.version,
|
||||
}));
|
||||
|
||||
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
|
||||
|
||||
@@ -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,
|
||||
@@ -27,9 +27,13 @@ import {
|
||||
Server,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Eye,
|
||||
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { DistroIcon, parseDistroFromVersion, getDistroColor } from "@/components/distro-icon";
|
||||
|
||||
import {
|
||||
getNodeMetrics,
|
||||
@@ -73,7 +77,7 @@ import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||
|
||||
interface MonitorViewProps {
|
||||
nodeMap: Map<number, { id: number; name: string; connectionStatus: string }>;
|
||||
nodeMap: Map<number, { id: number; name: string; connectionStatus: string; version?: string }>;
|
||||
viewMode?: "list" | "grid";
|
||||
}
|
||||
|
||||
@@ -165,6 +169,8 @@ const getColorByUsage = (usage?: number) => {
|
||||
|
||||
function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNodeMetric | null; onPress?: () => void }) {
|
||||
const isOnline = node.connectionStatus === "online";
|
||||
const distro = parseDistroFromVersion(node.version);
|
||||
const distroColor = getDistroColor(distro);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -181,7 +187,7 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
|
||||
<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">
|
||||
<Server className={`w-5 h-5 ${isOnline ? "text-success" : "text-danger"}`} />
|
||||
<DistroIcon distro={distro} className="w-5 h-5" style={{ color: isOnline ? distroColor : undefined }} />
|
||||
</div>
|
||||
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isOnline ? "bg-success" : "bg-danger"}`} />
|
||||
</div>
|
||||
@@ -263,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<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 = {
|
||||
checkerScanIntervalSec: 1,
|
||||
minIntervalSec: 1,
|
||||
@@ -469,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("加载指标失败");
|
||||
@@ -623,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]);
|
||||
@@ -648,13 +794,27 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
void loadResultsForModal();
|
||||
}, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]);
|
||||
|
||||
// Auto-load results for the resolved default monitor when entering detail view
|
||||
useEffect(() => {
|
||||
if (!detailNodeId) return;
|
||||
if (activeServiceMonitorId) return; // user already selected one
|
||||
// Find the first monitor belonging to this node (or panel-level)
|
||||
const firstMonitor = serviceMonitors.find(
|
||||
(m) => m.nodeId === detailNodeId || m.nodeId === 0,
|
||||
);
|
||||
if (firstMonitor && (!monitorResults[firstMonitor.id] || monitorResults[firstMonitor.id].length <= 1)) {
|
||||
void loadMonitorResults(firstMonitor.id, { rangeMs: serviceMonitorRangeMs });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detailNodeId, serviceMonitors]);
|
||||
|
||||
// Reload results for the active service monitor chart when time range changes
|
||||
useEffect(() => {
|
||||
if (!activeServiceMonitorId) return;
|
||||
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,
|
||||
@@ -666,52 +826,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]);
|
||||
|
||||
|
||||
|
||||
@@ -922,6 +1037,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
[resolvedServiceMonitorLimits.defaultIntervalSec, resolvedServiceMonitorLimits.minIntervalSec],
|
||||
);
|
||||
|
||||
// Backend batches DB writes every 30s, but latest API reads from in-memory cache.
|
||||
// The cache timestamp reflects the real check time (every ~1s), so stale detection
|
||||
// should still allow for the batch report interval + scan jitter.
|
||||
const SERVICE_MONITOR_REPORT_INTERVAL_MS = 30_000; // matches backend serviceMonitorReportInterval
|
||||
|
||||
const isResultStale = useCallback(
|
||||
(monitor: ServiceMonitorApiItem, latestResult: ServiceMonitorResultApiItem | null) => {
|
||||
if (monitor.enabled !== 1) {
|
||||
@@ -932,8 +1052,9 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
}
|
||||
|
||||
const intervalMs = resolveMonitorIntervalSec(monitor) * 1000;
|
||||
// Budget = batch report interval + one check interval + scan jitter + grace
|
||||
const budgetMs =
|
||||
intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
|
||||
SERVICE_MONITOR_REPORT_INTERVAL_MS + intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
|
||||
|
||||
return Date.now() - latestResult.timestamp > budgetMs;
|
||||
},
|
||||
@@ -973,46 +1094,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;
|
||||
@@ -1104,25 +1185,40 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroIcon distro={parseDistroFromVersion(node.version)} className="w-4 h-4 flex-shrink-0" style={{ color: isOnline ? getDistroColor(parseDistroFromVersion(node.version)) : undefined }} />
|
||||
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
|
||||
<div className="flex items-center gap-1 font-mono text-success-500">
|
||||
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span> ↑
|
||||
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5 font-mono text-success-500">
|
||||
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span>
|
||||
<div className="flex items-center justify-center p-[3px] rounded-full bg-success-50 dark:bg-success-500/10 text-success-500">
|
||||
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 font-mono text-primary-500">
|
||||
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span> ↓
|
||||
<div className="flex items-center gap-1.5 font-mono text-primary-500">
|
||||
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span>
|
||||
<div className="flex items-center justify-center p-[3px] rounded-full bg-primary-50 dark:bg-primary-500/10 text-primary-500">
|
||||
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
|
||||
<div className="flex items-center gap-1 font-mono text-default-600">
|
||||
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span> ↑
|
||||
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5 font-mono text-default-600">
|
||||
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span>
|
||||
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
|
||||
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 font-mono text-default-600">
|
||||
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span> ↓
|
||||
<div className="flex items-center gap-1.5 font-mono text-default-600">
|
||||
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span>
|
||||
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
|
||||
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -1238,83 +1334,19 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
)}
|
||||
|
||||
{/* Node metrics chart */}
|
||||
<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(metricsRangeMs)]}
|
||||
onSelectionChange={(keys) => {
|
||||
const v = Number(Array.from(keys)[0]);
|
||||
if (v > 0) setMetricsRangeMs(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={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>
|
||||
<NodeMetricsChartCard
|
||||
rangeMs={metricsRangeMs}
|
||||
onRangeChange={setMetricsRangeMs}
|
||||
activeMetricType={activeMetricType}
|
||||
onMetricTypeChange={setActiveMetricType}
|
||||
loading={metricsLoading}
|
||||
error={metricsError}
|
||||
truncated={metricsTruncated}
|
||||
maxRows={METRICS_MAX_ROWS}
|
||||
data={chartData}
|
||||
nodeId={selectedNodeId}
|
||||
onRefresh={loadMetrics}
|
||||
/>
|
||||
|
||||
|
||||
{/* Service monitors chart – same style as node metrics */}
|
||||
|
||||
@@ -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 (
|
||||
<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) {
|
||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
||||
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 }) => (
|
||||
<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
|
||||
// =====================
|
||||
@@ -583,123 +705,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ====== Quality History Chart (mirrors service monitor chart) ====== */}
|
||||
<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={qualityRangeMs} onChange={setQualityRangeMs} />
|
||||
<Button
|
||||
isLoading={qualityHistoryLoading}
|
||||
size="sm"
|
||||
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];
|
||||
}}
|
||||
/>
|
||||
{/* ====== Quality History Chart — isolated with React.memo ====== */}
|
||||
<QualityChartCard
|
||||
rangeMs={qualityRangeMs}
|
||||
onRangeChange={setQualityRangeMs}
|
||||
loading={qualityHistoryLoading}
|
||||
error={qualityHistoryError}
|
||||
data={qualityChartData}
|
||||
tunnelId={detailTunnelId}
|
||||
onRefresh={loadQualityHistory}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* ====== 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>
|
||||
{/* ====== Traffic Chart — isolated with React.memo ====== */}
|
||||
<TrafficChartCard
|
||||
rangeMs={tunnelRangeMs}
|
||||
onRangeChange={setTunnelRangeMs}
|
||||
loading={tunnelMetricsLoading}
|
||||
error={tunnelMetricsError}
|
||||
data={tunnelChartData}
|
||||
tunnelId={detailTunnelId}
|
||||
onRefresh={loadTunnelMetrics}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user