mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-15 01:04:22 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ebde9dca9 | |||
| 0a1ec60750 | |||
| 9ec35d2f2f | |||
| c914040b7d | |||
| 822362c44c | |||
| 80f5935b76 | |||
| 433c8aab13 | |||
| 949dfcd42d | |||
| 1580e4ee10 | |||
| 32e4f0f514 | |||
| ca3a643ef7 | |||
| ce2b234843 | |||
| ac3506847c | |||
| 6e3d604618 | |||
| 4417ece7cd | |||
| bab4371ba7 | |||
| 960c97cee4 | |||
| 9d05d75fd6 | |||
| c137bdcc63 |
@@ -1,9 +1,9 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Generated:** Sat Mar 21 2026
|
||||
**Commit:** 0767c9c
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta8
|
||||
**Tag:** 2.1.9-beta13
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/monitoring"
|
||||
@@ -20,26 +21,47 @@ type nodeCommander interface {
|
||||
SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error)
|
||||
}
|
||||
|
||||
const serviceMonitorReportInterval = 30 * time.Second // DB write interval per monitor
|
||||
|
||||
type Checker struct {
|
||||
repo *repo.Repository
|
||||
commander nodeCommander
|
||||
lastRun map[int64]int64
|
||||
inFlight map[int64]struct{}
|
||||
|
||||
mu sync.RWMutex
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
// In-memory latest result per monitor (for real-time API reads)
|
||||
latestResults map[int64]*model.ServiceMonitorResult
|
||||
lastDBWrite map[int64]int64 // last DB write timestamp per monitorID
|
||||
|
||||
mu sync.RWMutex
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
checking int32 // atomic flag: 1 = runChecks running, 0 = idle
|
||||
}
|
||||
|
||||
func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker {
|
||||
return &Checker{
|
||||
repo: repo,
|
||||
commander: commander,
|
||||
lastRun: make(map[int64]int64),
|
||||
inFlight: make(map[int64]struct{}),
|
||||
repo: repo,
|
||||
commander: commander,
|
||||
lastRun: make(map[int64]int64),
|
||||
inFlight: make(map[int64]struct{}),
|
||||
latestResults: make(map[int64]*model.ServiceMonitorResult),
|
||||
lastDBWrite: make(map[int64]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// GetLatestCached returns the in-memory latest results (updated every 1s).
|
||||
// Returns nil if no results are cached.
|
||||
func (c *Checker) GetLatestCached() []*model.ServiceMonitorResult {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
results := make([]*model.ServiceMonitorResult, 0, len(c.latestResults))
|
||||
for _, r := range c.latestResults {
|
||||
results = append(results, r)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (c *Checker) Start(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
@@ -52,7 +74,7 @@ func (c *Checker) Start(ctx context.Context) {
|
||||
limits := c.loadServiceMonitorLimits()
|
||||
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
|
||||
if scanInterval <= 0 {
|
||||
scanInterval = 30 * time.Second
|
||||
scanInterval = 1 * time.Second
|
||||
}
|
||||
|
||||
timer := time.NewTimer(scanInterval)
|
||||
@@ -87,6 +109,12 @@ func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult,
|
||||
}
|
||||
|
||||
func (c *Checker) runChecks(ctx context.Context) {
|
||||
// Skip if previous round is still running (interval < timeout guard)
|
||||
if !atomic.CompareAndSwapInt32(&c.checking, 0, 1) {
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&c.checking, 0)
|
||||
|
||||
if c == nil || c.repo == nil {
|
||||
return
|
||||
}
|
||||
@@ -174,6 +202,8 @@ func (c *Checker) runChecks(ctx context.Context) {
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
reportIntervalMs := int64(serviceMonitorReportInterval / time.Millisecond)
|
||||
|
||||
for i := 0; i < workerLimit; i++ {
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
@@ -188,14 +218,26 @@ func (c *Checker) runChecks(ctx context.Context) {
|
||||
}
|
||||
ts := time.Now().UnixMilli()
|
||||
result := c.executeCheck(&m, ts, limits)
|
||||
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
|
||||
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
|
||||
}
|
||||
|
||||
// Always update in-memory cache for real-time reads
|
||||
c.mu.Lock()
|
||||
c.latestResults[m.ID] = result
|
||||
c.lastRun[m.ID] = result.Timestamp
|
||||
delete(c.inFlight, m.ID)
|
||||
|
||||
// Only write to DB every 30s per monitor
|
||||
lastWrite := c.lastDBWrite[m.ID]
|
||||
writeToDB := ts-lastWrite >= reportIntervalMs
|
||||
if writeToDB {
|
||||
c.lastDBWrite[m.ID] = ts
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if writeToDB {
|
||||
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
|
||||
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -744,6 +746,16 @@ func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
// Try in-memory cache first (updated every 1s)
|
||||
if h.healthCheck != nil {
|
||||
cached := h.healthCheck.GetLatestCached()
|
||||
if len(cached) > 0 {
|
||||
response.WriteJSON(w, response.OK(cached))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to database
|
||||
results, err := h.repo.GetLatestServiceMonitorResults()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,19 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelQualityProbeInterval = 10 * time.Second
|
||||
tunnelQualityProbeTimeout = 8 * time.Second
|
||||
tunnelQualityPingTimeoutMs = 5000
|
||||
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
|
||||
tunnelQualityPruneInterval = 10 * time.Minute
|
||||
tunnelQualityProbeInterval = 1 * time.Second
|
||||
tunnelQualityProbeTimeout = 8 * time.Second
|
||||
tunnelQualityPingTimeoutMs = 5000
|
||||
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
|
||||
tunnelQualityPruneInterval = 10 * time.Minute
|
||||
tunnelQualityReportInterval = 30 * time.Second // DB save interval
|
||||
)
|
||||
|
||||
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
|
||||
@@ -27,6 +29,9 @@ type tunnelQualitySnapshot struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
|
||||
// internal fields for db reporting
|
||||
lastDBWrite int64 `json:"-"`
|
||||
}
|
||||
|
||||
// tunnelQualityProber runs periodic TCP ping probes against all enabled tunnels.
|
||||
@@ -38,6 +43,7 @@ type tunnelQualityProber struct {
|
||||
cancel context.CancelFunc
|
||||
interval time.Duration
|
||||
lastPrune int64
|
||||
probing int32 // atomic flag: 1 = probeAll running, 0 = idle
|
||||
}
|
||||
|
||||
// newTunnelQualityProber creates a new prober (not yet running).
|
||||
@@ -120,6 +126,12 @@ func (p *tunnelQualityProber) maybePrune() {
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeAll() {
|
||||
// Skip if previous probe round is still running (interval < timeout guard)
|
||||
if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) {
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&p.probing, 0)
|
||||
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
@@ -136,7 +148,7 @@ func (p *tunnelQualityProber) probeAll() {
|
||||
|
||||
// Probe tunnels concurrently with a worker limit
|
||||
// (mirrors health.Checker worker pool pattern)
|
||||
const maxWorkers = 4
|
||||
const maxWorkers = 20
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -303,8 +315,28 @@ func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
|
||||
}
|
||||
|
||||
// Update in-memory cache (latest per tunnel)
|
||||
// Retain the lastDBWrite timestamp if it exists, so we only DB write every 30s
|
||||
var lastWrite int64
|
||||
if existing, ok := p.cache.Load(snap.TunnelID); ok {
|
||||
if eg, ok := existing.(*tunnelQualitySnapshot); ok {
|
||||
lastWrite = eg.lastDBWrite
|
||||
}
|
||||
}
|
||||
snap.lastDBWrite = lastWrite
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
writeToDB := false
|
||||
if now-snap.lastDBWrite >= int64(tunnelQualityReportInterval/time.Millisecond) {
|
||||
writeToDB = true
|
||||
snap.lastDBWrite = now
|
||||
}
|
||||
|
||||
p.cache.Store(snap.TunnelID, snap)
|
||||
|
||||
if !writeToDB {
|
||||
return
|
||||
}
|
||||
|
||||
// Persist to database (history)
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
|
||||
@@ -29,10 +29,10 @@ const (
|
||||
|
||||
func DefaultServiceMonitorLimits() ServiceMonitorLimits {
|
||||
return ServiceMonitorLimits{
|
||||
CheckerScanIntervalSec: 30,
|
||||
WorkerLimit: 5,
|
||||
MinIntervalSec: 30,
|
||||
DefaultIntervalSec: 60,
|
||||
CheckerScanIntervalSec: 1,
|
||||
WorkerLimit: 20,
|
||||
MinIntervalSec: 1,
|
||||
DefaultIntervalSec: 1,
|
||||
MinTimeoutSec: 1,
|
||||
DefaultTimeoutSec: 5,
|
||||
MaxTimeoutSec: 60,
|
||||
|
||||
@@ -3422,10 +3422,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
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
|
||||
return &WebSocketReporter{
|
||||
url: serverURL,
|
||||
curBackoff: initialBackoff, // 当前退避间隔
|
||||
pingInterval: 5 * time.Second, // 指标上报间隔
|
||||
pingInterval: 1 * time.Second, // 指标上报间隔(每秒采集)
|
||||
configInterval: 10 * time.Minute, // 配置上报间隔
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
@@ -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,42 @@
|
||||
# 060 Nezha-style Monitoring (1s test, 30s report)
|
||||
|
||||
## Objective
|
||||
Update all monitoring subsystems to test every 1 second and report (write to DB) every 30 seconds, matching Nezha-style monitoring behavior.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Tunnel Quality Prober (`go-backend/internal/http/handler/tunnel_quality_prober.go`)
|
||||
- [x] Change `tunnelQualityProbeInterval` from 10s to 1s
|
||||
- [x] Add `tunnelQualityReportInterval = 30s` for DB write throttling
|
||||
- [x] Update `storeResult` to cache in-memory every tick, write to DB only every 30s per tunnel
|
||||
- [x] Add atomic `probing` flag to prevent overlapping `probeAll()` goroutine pile-up
|
||||
- [x] Increase `maxWorkers` from 4 to 20
|
||||
|
||||
### 2. Service Monitor Checker (`go-backend/internal/health/checker.go`)
|
||||
- [x] Add `serviceMonitorReportInterval = 30s` for DB write throttling
|
||||
- [x] Add `latestResults` in-memory map and `lastDBWrite` map per monitor
|
||||
- [x] Add `GetLatestCached()` method for real-time API reads
|
||||
- [x] Add atomic `checking` flag to prevent overlapping `runChecks()` goroutine pile-up
|
||||
- [x] Modify worker goroutines to always update in-memory cache, only write to DB every 30s
|
||||
|
||||
### 3. Service Monitor Limits (`go-backend/internal/monitoring/limits.go`)
|
||||
- [x] Change `CheckerScanIntervalSec` default from 30 to 1
|
||||
- [x] Change `WorkerLimit` default from 5 to 20
|
||||
- [x] Change `MinIntervalSec` default from 30 to 1
|
||||
- [x] Change `DefaultIntervalSec` default from 60 to 1
|
||||
|
||||
### 4. Monitoring API Handler (`go-backend/internal/http/handler/monitoring.go`)
|
||||
- [x] Update `monitorServiceLatestResultsHandler` to prefer in-memory cached results from `healthCheck.GetLatestCached()`
|
||||
|
||||
### 5. Agent WebSocket Reporter (`go-gost/x/socket/websocket_reporter.go`)
|
||||
- [x] Change `pingInterval` (metric reporting) from 5s to 1s
|
||||
|
||||
### 6. Frontend - Tunnel Monitor (`vite-frontend/src/pages/node/tunnel-monitor-view.tsx`)
|
||||
- [x] Change `QUALITY_POLL_INTERVAL` from 10s to 1s
|
||||
- [x] Update detail view text: "自动探测中(每秒测试,30秒上报)"
|
||||
- [x] Update list view text: "每秒探测 · 更新于 ..."
|
||||
|
||||
### 7. Frontend - Service Monitor (`vite-frontend/src/pages/node/monitor-view.tsx`)
|
||||
- [x] Change `DEFAULT_SERVICE_MONITOR_LIMITS` defaults to match backend (1s intervals)
|
||||
- [x] Change service monitor + latest results polling from 30s to 1s
|
||||
- [x] Update info bar text: "每秒测试,30秒上报"
|
||||
@@ -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
|
||||
```
|
||||
@@ -85,11 +85,22 @@
|
||||
shortcutIconLink.removeAttribute('type');
|
||||
}
|
||||
|
||||
// 立即检测系统主题并应用
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
// 立即检测主题并应用(优先读取 localStorage 中的用户偏好)
|
||||
var savedMode = '';
|
||||
try { savedMode = localStorage.getItem('flvx:theme') || ''; } catch (_) {}
|
||||
|
||||
var useDark;
|
||||
if (savedMode === 'dark') {
|
||||
useDark = true;
|
||||
} else if (savedMode === 'light') {
|
||||
useDark = false;
|
||||
} else {
|
||||
// "system" 或无保存:跟随系统
|
||||
useDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
// 立即设置主题类,避免闪烁
|
||||
if (isDarkMode) {
|
||||
if (useDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
} else {
|
||||
@@ -97,9 +108,13 @@
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
if (window.matchMedia) {
|
||||
// 仅在"跟随系统"模式下监听系统主题变化
|
||||
if ((!savedMode || savedMode === 'system') && window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||
// 运行时可能已由 React 接管,这里仅做 fallback
|
||||
var currentMode = '';
|
||||
try { currentMode = localStorage.getItem('flvx:theme') || ''; } catch (_) {}
|
||||
if (currentMode && currentMode !== 'system') return; // 用户已手动选择,不干预
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
|
||||
@@ -466,6 +466,7 @@ export interface MonitorNodeApiItem {
|
||||
inx: number;
|
||||
name: string;
|
||||
status: number;
|
||||
version?: string;
|
||||
updatedTime: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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)".
|
||||
|
||||
const distroColors: Record<string, string> = {
|
||||
ubuntu: "#E95420",
|
||||
debian: "#A81D33",
|
||||
centos: "#262577",
|
||||
rocky: "#10B981",
|
||||
alma: "#0F4266",
|
||||
alpine: "#0D597F",
|
||||
fedora: "#51A2DA",
|
||||
arch: "#1793D1",
|
||||
opensuse: "#73BA25",
|
||||
suse: "#73BA25",
|
||||
rhel: "#EE0000",
|
||||
redhat: "#EE0000",
|
||||
oracle: "#F80000",
|
||||
amazon: "#FF9900",
|
||||
amzn: "#FF9900",
|
||||
kali: "#557C94",
|
||||
mint: "#87CF3E",
|
||||
gentoo: "#54487A",
|
||||
void: "#478061",
|
||||
nixos: "#7EBAE4",
|
||||
manjaro: "#35BF5C",
|
||||
};
|
||||
|
||||
function UbuntuIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<circle cx="5.5" cy="15.5" r="2" />
|
||||
<circle cx="18.5" cy="15.5" r="2" />
|
||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DebianIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm3.5 14.5c-1.5 1.2-3 1.5-4.5 1.3-2-.3-3.5-1.5-4.2-3.3-.8-2-.5-4 .8-5.6 1-1.2 2.3-1.9 3.9-2 1.2 0 2 .3 2 .3l-.1 1s-.8-.4-1.7-.3c-2 .1-3.5 1.7-3.4 3.8.1 1.8 1.3 3.2 3 3.5 1.2.2 2.3-.1 3.2-.8l1 2.1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CentOSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<rect x="3" y="3" width="8" height="8" rx="1" opacity="0.9" />
|
||||
<rect x="13" y="3" width="8" height="8" rx="1" opacity="0.7" />
|
||||
<rect x="3" y="13" width="8" height="8" rx="1" opacity="0.7" />
|
||||
<rect x="13" y="13" width="8" height="8" rx="1" opacity="0.5" />
|
||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AlpineIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M12 3L2 19h20L12 3zm0 4l6 10H6l6-10z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FedoraIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M12 6v6h6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<text x="8" y="17" fontSize="6" fontWeight="bold" fill="currentColor">f</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M12 2l-2 5c1 1.5 1.5 3 2 5 .5-2 1-3.5 2-5L12 2zM8.5 10C6.5 14 4 17 2 20c2.5-1.5 4.5-2.5 7-3-1-2-1.5-4.5-0.5-7zM15.5 10c1 2.5.5 5-.5 7 2.5.5 4.5 1.5 7 3-2-3-4.5-6-6.5-10z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultLinuxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
// Tux-inspired simple penguin outline
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M12 2C9.8 2 8 4.1 8 6.5c0 1.2.4 2.2 1 3l-1.5 4.5c-.3 1-.5 2-.5 3 0 2.2 2.2 4 5 4s5-1.8 5-4c0-1-.2-2-.5-3L15 9.5c.6-.8 1-1.8 1-3C16 4.1 14.2 2 12 2zm-1.5 6a1 1 0 110-2 1 1 0 010 2zm3 0a1 1 0 110-2 1 1 0 010 2zm-3 3h3l.5 1h-4l.5-1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type DistroIconProps = SVGProps<SVGSVGElement> & {
|
||||
distro: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect the Linux distro from a version string like "v1.0.0 (ubuntu/amd64)"
|
||||
* and return the normalized distro key.
|
||||
*/
|
||||
export function parseDistroFromVersion(version?: string): string {
|
||||
if (!version) return "linux";
|
||||
const lower = version.toLowerCase();
|
||||
|
||||
// Try to extract from parenthetical format: (distro/arch)
|
||||
const match = lower.match(/\(([^/)\s]+)/);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
// Fallback: check for known distro names anywhere in the string
|
||||
const knownDistros = [
|
||||
"ubuntu", "debian", "centos", "rocky", "alma", "alpine",
|
||||
"fedora", "arch", "opensuse", "suse", "rhel", "redhat",
|
||||
"oracle", "amazon", "amzn", "kali", "mint", "gentoo",
|
||||
"void", "nixos", "manjaro",
|
||||
];
|
||||
for (const d of knownDistros) {
|
||||
if (lower.includes(d)) return d;
|
||||
}
|
||||
|
||||
return "linux";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a branded color for the detected distro.
|
||||
*/
|
||||
export function getDistroColor(distro: string): string {
|
||||
const key = distro.toLowerCase();
|
||||
for (const [name, color] of Object.entries(distroColors)) {
|
||||
if (key.includes(name)) return color;
|
||||
}
|
||||
return "#6366F1"; // default indigo for unknown linux
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an SVG icon for the given Linux distribution.
|
||||
*/
|
||||
export function DistroIcon({ distro, ...props }: DistroIconProps) {
|
||||
const key = distro.toLowerCase();
|
||||
|
||||
if (key.includes("ubuntu")) return <UbuntuIcon {...props} />;
|
||||
if (key.includes("debian")) return <DebianIcon {...props} />;
|
||||
if (key.includes("centos") || key.includes("rocky") || key.includes("alma")) return <CentOSIcon {...props} />;
|
||||
if (key.includes("alpine")) return <AlpineIcon {...props} />;
|
||||
if (key.includes("fedora")) return <FedoraIcon {...props} />;
|
||||
if (key.includes("arch") || key.includes("manjaro")) return <ArchIcon {...props} />;
|
||||
|
||||
return <DefaultLinuxIcon {...props} />;
|
||||
}
|
||||
@@ -12,6 +12,12 @@ import React from "react";
|
||||
import "@/themes/loader";
|
||||
|
||||
import { ThemeProvider as ThemeContextProvider } from "@/themes/context";
|
||||
import { initThemeSystem } from "@/themes/registry";
|
||||
|
||||
// Restore saved theme SYNCHRONOUSLY at module load time — before first render.
|
||||
// This ensures the CSS tokens are injected before any component renders,
|
||||
// preventing a flash of the default theme.
|
||||
initThemeSystem();
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import { BackIcon, SettingsIcon } from "@/components/icons";
|
||||
import { ThemeSettings } from "@/components/theme-settings";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
|
||||
import {
|
||||
@@ -979,6 +980,11 @@ export default function ConfigPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 主题设置 */}
|
||||
<div className="mt-6">
|
||||
<ThemeSettings />
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 shadow-sm overflow-hidden">
|
||||
<div className="h-10 flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -609,6 +609,8 @@ export default function NodePage() {
|
||||
|
||||
if (!metric || typeof metric !== "object") return node;
|
||||
|
||||
const incomingUptime = metric.uptime ?? 0;
|
||||
|
||||
return {
|
||||
...node,
|
||||
connectionStatus: "online",
|
||||
@@ -620,7 +622,7 @@ export default function NodePage() {
|
||||
downloadTraffic: metric.netInBytes ?? metric.bytes_received ?? 0,
|
||||
uploadSpeed: metric.netOutSpeed ?? metric.net_out_speed ?? 0,
|
||||
downloadSpeed: metric.netInSpeed ?? metric.net_in_speed ?? 0,
|
||||
uptime: metric.uptime ?? 0,
|
||||
uptime: incomingUptime || node.systemInfo?.uptime || 0,
|
||||
diskUsage: metric.diskUsage ?? metric.disk_usage,
|
||||
load1: metric.load1,
|
||||
load5: metric.load5,
|
||||
@@ -1947,7 +1949,7 @@ export default function NodePage() {
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${expiryMeta.accentClassName}`}
|
||||
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${node.expiryReminderDismissed ? "" : expiryMeta.accentClassName}`}
|
||||
data-node-card="true"
|
||||
>
|
||||
<CardHeader className="pb-3 md:pb-3">
|
||||
|
||||
@@ -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>
|
||||
@@ -264,9 +270,9 @@ type MetricType =
|
||||
const METRICS_MAX_ROWS = 5000;
|
||||
|
||||
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
|
||||
checkerScanIntervalSec: 30,
|
||||
minIntervalSec: 30,
|
||||
defaultIntervalSec: 60,
|
||||
checkerScanIntervalSec: 1,
|
||||
minIntervalSec: 1,
|
||||
defaultIntervalSec: 1,
|
||||
minTimeoutSec: 1,
|
||||
defaultTimeoutSec: 5,
|
||||
maxTimeoutSec: 60,
|
||||
@@ -386,27 +392,31 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
|
||||
const metric = raw as Record<string, unknown>;
|
||||
const receivedAt = Date.now();
|
||||
const normalized: RealtimeNodeMetric = {
|
||||
receivedAt,
|
||||
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
|
||||
memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0),
|
||||
diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0),
|
||||
netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0),
|
||||
netOutBytes: Number(metric.netOutBytes ?? metric.bytes_transmitted ?? 0),
|
||||
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
|
||||
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
|
||||
load1: Number(metric.load1 ?? 0),
|
||||
load5: Number(metric.load5 ?? 0),
|
||||
load15: Number(metric.load15 ?? 0),
|
||||
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
|
||||
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
|
||||
uptime: Number(metric.uptime ?? 0),
|
||||
};
|
||||
const incomingUptime = Number(metric.uptime ?? 0);
|
||||
|
||||
setRealtimeNodeMetrics((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: normalized,
|
||||
}));
|
||||
setRealtimeNodeMetrics((prev) => {
|
||||
const normalized: RealtimeNodeMetric = {
|
||||
receivedAt,
|
||||
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0),
|
||||
memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0),
|
||||
diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0),
|
||||
netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0),
|
||||
netOutBytes: Number(metric.netOutBytes ?? metric.bytes_transmitted ?? 0),
|
||||
netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
|
||||
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
|
||||
load1: Number(metric.load1 ?? 0),
|
||||
load5: Number(metric.load5 ?? 0),
|
||||
load15: Number(metric.load15 ?? 0),
|
||||
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
|
||||
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
|
||||
uptime: incomingUptime || prev[nodeId]?.uptime || 0,
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[nodeId]: normalized,
|
||||
};
|
||||
});
|
||||
setRealtimeNodeStatus((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: "online",
|
||||
@@ -619,7 +629,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
const timer = window.setInterval(() => {
|
||||
void loadServiceMonitors({ silent: true });
|
||||
void loadLatestMonitorResults();
|
||||
}, 30_000);
|
||||
}, 1_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadLatestMonitorResults, loadServiceMonitors]);
|
||||
@@ -1100,25 +1110,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>
|
||||
@@ -1412,7 +1437,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
<div className="flex items-center gap-3 min-w-0 flex-wrap">
|
||||
<Chip size="sm" color="primary" variant="flat">{resolvedActiveMonitor.type.toUpperCase()}</Chip>
|
||||
<span className="font-mono text-xs text-default-500">{resolvedActiveMonitor.target}</span>
|
||||
<span className="text-xs text-default-500">间隔 {resolvedActiveMonitor.intervalSec}s</span>
|
||||
<span className="text-xs text-default-500">每秒测试,30秒上报</span>
|
||||
{activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? (
|
||||
<span className="font-mono text-xs font-semibold text-success">{activeLatestResult.latencyMs.toFixed(0)}ms</span>
|
||||
) : null}
|
||||
|
||||
@@ -48,13 +48,49 @@ const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Heuristic to verify the parsed object actually contains system-info fields.
|
||||
* Prevents non-metric messages (command responses, etc.) from being mistakenly
|
||||
* treated as system info, which would reset all values to 0 and cause UI flicker.
|
||||
*/
|
||||
const SYSTEM_INFO_KEYS = [
|
||||
"uptime",
|
||||
"cpu_usage",
|
||||
"memory_usage",
|
||||
"disk_usage",
|
||||
"bytes_received",
|
||||
"bytes_transmitted",
|
||||
"net_in_speed",
|
||||
"net_out_speed",
|
||||
"tcp_conns",
|
||||
"udp_conns",
|
||||
"load1",
|
||||
"load5",
|
||||
"load15",
|
||||
] as const;
|
||||
|
||||
const looksLikeSystemInfo = (raw: RawSystemInfo): boolean => {
|
||||
let matched = 0;
|
||||
|
||||
for (const key of SYSTEM_INFO_KEYS) {
|
||||
if (key in raw && raw[key] !== undefined) {
|
||||
matched++;
|
||||
if (matched >= 3) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const buildNodeSystemInfo = (
|
||||
messageData: unknown,
|
||||
previous: NodeSystemInfo | null | undefined,
|
||||
): NodeSystemInfo | null => {
|
||||
const raw = parseRawSystemInfo(messageData);
|
||||
|
||||
if (!raw) {
|
||||
if (!raw || !looksLikeSystemInfo(raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ interface TunnelMonitorViewProps {
|
||||
viewMode?: "list" | "grid";
|
||||
}
|
||||
|
||||
const QUALITY_POLL_INTERVAL = 10_000; // 10 seconds
|
||||
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
|
||||
|
||||
const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
||||
const date = new Date(ts);
|
||||
@@ -572,7 +572,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
{/* Auto-probe status */}
|
||||
<div className="flex items-center gap-2 text-xs text-default-500">
|
||||
<LiveDot />
|
||||
<span>自动探测中(每10秒)</span>
|
||||
<span>自动探测中(每秒测试,30秒上报)</span>
|
||||
{quality?.timestamp && (
|
||||
<span className="text-default-400">
|
||||
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
@@ -712,7 +712,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
{lastQualityUpdate && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-default-500">
|
||||
<LiveDot />
|
||||
<span>自动探测 · 更新于 {lastQualityUpdate}</span>
|
||||
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { reinitializeBaseURL } from "@/api/network";
|
||||
import { getConfigByName, updateConfig } from "@/api";
|
||||
import { BackIcon } from "@/components/icons";
|
||||
import { ThemeSettings } from "@/components/theme-settings";
|
||||
import {
|
||||
type UpdateReleaseChannel,
|
||||
getUpdateReleaseChannel,
|
||||
@@ -182,7 +181,6 @@ export const SettingsPage = () => {
|
||||
{/* 内容区域 */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="space-y-6">
|
||||
<ThemeSettings />
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
getSavedMode,
|
||||
saveMode,
|
||||
getEffectiveMode,
|
||||
initThemeSystem,
|
||||
registerTheme,
|
||||
unregisterTheme,
|
||||
type ThemeMode,
|
||||
@@ -95,11 +94,6 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// Re-render whenever register changes
|
||||
useSyncExternalStore(originalSubscribe, getSnapshot);
|
||||
|
||||
// Initialise on mount
|
||||
useEffect(() => {
|
||||
initThemeSystem();
|
||||
}, []);
|
||||
|
||||
// Listen for system mode changes
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
Reference in New Issue
Block a user