Compare commits

...

49 Commits

Author SHA1 Message Date
sagit 9d50071915 chore: release 2.1.9-rc7 (#379)
Bump version to 2.1.9-rc7 in AGENTS.md
2026-03-24 07:22:52 +08:00
sagitchu ceceee6ebd chore: bump version to 2.1.9-rc7 2026-03-24 07:21:20 +08:00
sagitchu 11051f5517 chore: finalize release plan 063 2026-03-24 07:18:56 +08:00
sagit ff2c7c4959 chore: release 2.1.9-rc6 (#378)
Bump version to 2.1.9-rc6 in AGENTS.md
2026-03-24 07:18:26 +08:00
sagitchu 6364b96935 chore: bump version to 2.1.9-rc6 2026-03-24 07:16:01 +08:00
sagit 409f0a232a fix: bump npm package version to 2.1.9-rc5 (#376)
Bumps version in package.json and AGENTS.md
2026-03-24 00:58:34 +08:00
sagitchu f79994e0e0 fix: bump npm package version to 2.1.9-rc5 to fix CI error 2026-03-24 00:56:08 +08:00
sagit 9fdb16d035 chore: bump version to 2.1.9-rc4 (#375)
Bump version tag in AGENTS.md
2026-03-23 23:01:58 +08:00
sagitchu 53b632a6f7 chore: bump version to 2.1.9-rc4 2026-03-23 22:59:47 +08:00
sagit bf7b2a0740 fix: re-assign tunnel ports automatically for out-of-range entries (#373) (#374)
Automatically re-assign ports from available ranges instead of rejecting
tunnel modification when adding new entry nodes.
2026-03-23 22:59:13 +08:00
sagitchu 8475bc27bb fix: re-assign port automatically if out of range for new tunnel entries (fixes #373) 2026-03-23 22:57:49 +08:00
sagit 5d01572eff release: 2.1.9-rc3 (#371)
Sync all changes and fix service monitor stale detection
2026-03-22 19:06:43 +08:00
sagitchu a353faaa71 chore: release 2.1.9-rc3 (fix service monitor stale detection) 2026-03-22 19:05:31 +08:00
sagit e7b25004ba Update monitor rendering (#370)
Merge all changes into main for RC2 release.
2026-03-22 17:56:14 +08:00
sagitchu 3826cb02c0 chore: update monitor rendering and release rc2 2026-03-22 17:55:10 +08:00
sagitchu a1fee8e432 Merge remote-tracking branch 'origin/main' into fix-agents-v14-final 2026-03-22 13:33:58 +08:00
sagitchu aafdb78482 feat: node logo by distro types and official icons 2026-03-22 13:33:37 +08:00
sagit 16b545d8cd chore: update AGENTS.md for release 2.1.9-beta14 (#368) 2026-03-22 05:05:50 +00:00
sagitchu 45065178b8 chore: update AGENTS.md for release 2.1.9-beta14 2026-03-22 13:04:43 +08:00
sagit 8ebde9dca9 feat: node OS logo and UI fixes (#367)
* chore: update AGENTS.md with next release info

* feat: node OS logo, UI rate fix, and monitor trend updates
2026-03-22 05:03:04 +00:00
sagit 0a1ec60750 fix: qualify tunnel_metric columns (#366)
fix ambiguous column reference in tunnel_metric upserts
2026-03-21 19:42:00 +08:00
sagitchu 9ec35d2f2f merge main into sync-agents-v12 2026-03-21 19:40:44 +08:00
sagitchu c914040b7d fix(repo): qualify tunnel_metric columns to avoid ambiguity in ON CONFLICT 2026-03-21 19:40:16 +08:00
sagit 822362c44c Update AGENTS.md for release v2.1.9-beta12 (#365)
Updating commit SHA and tag in AGENTS.md for the latest release.
2026-03-21 19:24:51 +08:00
sagitchu 80f5935b76 chore: update AGENTS.md for release v2.1.9-beta12 2026-03-21 19:23:20 +08:00
sagit 433c8aab13 Tunnel metrics ingestion logging improvements (#364)
Added debug logging for better tunnel metrics monitoring.
2026-03-21 19:22:32 +08:00
sagitchu 949dfcd42d Merge branch 'main' into sync-all-changes
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-03-21 19:21:26 +08:00
sagitchu 1580e4ee10 chore: [monitoring] improve tunnel metric ingestion logging 2026-03-21 19:20:23 +08:00
sagit 32e4f0f514 feat: sync all changes (#363)
Automated changes to monitoring and system info
2026-03-21 18:41:42 +08:00
sagitchu ca3a643ef7 feat: optimize node monitoring and system info 2026-03-21 18:40:28 +08:00
sagitchu ce2b234843 chore: update AGENTS.md for release v2.1.9-beta10 2026-03-21 17:31:08 +08:00
sagit ac3506847c feat: implement tunnel quality polling and service monitor tuning (#362)
Implement 1s test, 30s report pattern across all monitoring subsystems.
2026-03-21 17:30:07 +08:00
sagitchu 6e3d604618 feat: implement tunnel quality polling and service monitor tuning to 1s/30s intervals 2026-03-21 17:28:52 +08:00
sagit 4417ece7cd fix: renewal reminder styling and position (#361)
Fixes the styling of the renewal reminder for nodes that have been
dismissed.
2026-03-21 10:04:02 +08:00
sagitchu bab4371ba7 fix: renewal reminder styling and position 2026-03-21 10:02:52 +08:00
sagit 960c97cee4 release: v2.1.9-beta9 (#360)
Theme persistence fix and version bump
2026-03-20 23:39:42 +08:00
sagitchu 9d05d75fd6 docs: update AGENTS.md for release v2.1.9-beta9 2026-03-20 23:37:47 +08:00
sagitchu c137bdcc63 fix: theme persistence and update version to 2.1.9-beta9 2026-03-20 23:37:35 +08:00
sagit b7065f6e99 feat: implement theme selection and system integration (#359)
Integrated theme selection into settings page and unified theme
provider.
2026-03-20 22:49:06 +08:00
sagitchu bd4e1f66cb feat: implement theme selection and system integration 2026-03-20 22:47:49 +08:00
sagitchu 9f0670f4d0 docs: complete plan 058 2026-03-20 22:20:49 +08:00
sagit 32e338d295 fix: include missing field in repository (#358)
Included missing field in repository.
2026-03-20 22:20:15 +08:00
sagitchu f6a753baa3 fix: include missing field in repository 2026-03-20 22:18:45 +08:00
sagit 322a10bb9d chore: release 2.1.9-beta7 (#357)
Sync current changes to 2.1.9-beta7.
2026-03-20 22:18:17 +08:00
sagitchu 27c13d6c47 chore: release 2.1.9-beta7 2026-03-20 22:15:51 +08:00
sagit f45f96063a fix: hide remote nodes from monitor view (#356)
Filter out nodes with is_remote=1 from the monitor API endpoint, as per
user requirement.
2026-03-20 18:42:58 +08:00
sagitchu 1780be73b9 fix(monitor): hide remote nodes from monitor view 2026-03-20 18:41:31 +08:00
sagit addf8e2089 feat: implement Uptime Kuma style historic quality bars for tunnel monitor (#355) 2026-03-20 14:17:16 +08:00
sagit 75cd60ea3e feat(ui): update list view tables styling for Node, Monitor, User, Tunnel, and Limit (#354) 2026-03-20 13:15:15 +08:00
65 changed files with 3484 additions and 615 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Thu Mar 19 2026
**Commit:** 6458b5a
**Generated:** Tue Mar 24 2026
**Commit:** 8ebde9d
**Branch:** main
**Tag:** 2.1.9-alpha5
**Tag:** 2.1.9-rc7
## 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.
+4 -4
View File
@@ -33,13 +33,13 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
#### 安装特定版本
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
面板端(以 2.1.0 为例):
面板端(以 2.1.9-beta6 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(以 2.1.0 为例):
节点端(以 2.1.9-beta6 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### PostgreSQL 部署(Docker Compose)
+5
View File
@@ -1,5 +1,10 @@
# GO BACKEND KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
+53 -11
View File
@@ -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)
}
}
}
}
}()
+4 -1
View File
@@ -1,6 +1,9 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
@@ -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,
})
}
@@ -695,6 +697,30 @@ func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Re
return
}
// If start/end time range is provided, use time-based query (mirrors node metrics / tunnel quality pattern).
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr != "" && endStr != "" {
startMs, err1 := strconv.ParseInt(startStr, 10, 64)
endMs, err2 := strconv.ParseInt(endStr, 10, 64)
if err1 != nil || err2 != nil || startMs <= 0 || endMs <= 0 || endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
results, err := h.repo.GetServiceMonitorResultsByTimeRange(monitorID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
return
}
// Fallback: count-based limit query (backward compat).
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
@@ -720,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()))
@@ -1107,6 +1107,15 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
continue
}
// If the existing port is outside any entry node's allowed range,
// pick a new random port that satisfies all entry nodes.
if !h.isPortValidForAllEntryNodes(port, entryNodeIDs) {
newPort := h.pickTunnelPort(tunnelID)
if newPort > 0 {
port = newPort
}
}
var entries []forwardPortReplaceEntry
if allowInIP {
entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port)
@@ -1120,6 +1129,25 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
}
}
func (h *Handler) isPortValidForAllEntryNodes(port int, entryNodeIDs []int64) bool {
if port <= 0 {
return false
}
for _, nodeID := range entryNodeIDs {
node, err := h.getNodeRecord(nodeID)
if err != nil {
continue
}
if validateLocalNodePort(node, port) != nil {
return false
}
if validateRemoteNodePort(node, port) != nil {
return false
}
}
return true
}
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))
}
}
@@ -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 {
@@ -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)
}
+4 -4
View File
@@ -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,
+3 -3
View File
@@ -675,9 +675,9 @@ func (NodeMetric) TableName() string { return "node_metric" }
type TunnelMetric struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
Timestamp int64 `gorm:"not null;index:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"`
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
Timestamp int64 `gorm:"not null;uniqueIndex:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"`
BytesIn int64 `gorm:"column:bytes_in" json:"bytesIn"`
BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"`
Connections int64 `gorm:"column:connections" json:"connections"`
+81 -15
View File
@@ -677,9 +677,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
"version": nullableString(n.Version),
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
"status": n.Status, "isRemote": n.IsRemote,
"remoteUrl": nullableString(n.RemoteURL),
"remoteToken": nullableString(n.RemoteToken),
"remoteConfig": nullableString(n.RemoteConfig),
"remoteUrl": nullableString(n.RemoteURL),
"remoteToken": nullableString(n.RemoteToken),
"remoteConfig": nullableString(n.RemoteConfig),
"expiryReminderDismissed": n.ExpiryReminderDismissed,
})
}
return items, nil
@@ -3319,15 +3320,61 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model
if r == nil || r.db == nil {
return nil, nil
}
rangeMs := endMs - startMs
const maxRawRangeMs = int64(60 * 60 * 1000) // 1 hour — return raw data for short ranges
const targetPoints = 500 // target number of chart points for downsampled data
// For short ranges, return raw data (full resolution).
if rangeMs <= maxRawRangeMs {
var metrics []model.NodeMetric
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Order("timestamp ASC").
Limit(5000).
Find(&metrics).Error
return metrics, err
}
// For longer ranges, downsample via SQL aggregation to keep the response small and fast.
bucketMs := rangeMs / targetPoints
if bucketMs < 1000 {
bucketMs = 1000 // minimum 1-second buckets
}
bucketExpr := fmt.Sprintf("(timestamp / %d * %d)", bucketMs, bucketMs)
groupExpr := fmt.Sprintf("timestamp / %d", bucketMs)
var metrics []model.NodeMetric
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Order("timestamp DESC").
Limit(5000).
Find(&metrics).Error
if len(metrics) > 1 {
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
metrics[i], metrics[j] = metrics[j], metrics[i]
}
err := r.db.Model(&model.NodeMetric{}).
Select(
fmt.Sprintf(
"? AS node_id, "+
"CAST(%s AS INTEGER) AS timestamp, "+
"AVG(cpu_usage) AS cpu_usage, "+
"AVG(mem_usage) AS mem_usage, "+
"AVG(disk_usage) AS disk_usage, "+
"CAST(AVG(net_in_bytes) AS INTEGER) AS net_in_bytes, "+
"CAST(AVG(net_out_bytes) AS INTEGER) AS net_out_bytes, "+
"CAST(AVG(net_in_speed) AS INTEGER) AS net_in_speed, "+
"CAST(AVG(net_out_speed) AS INTEGER) AS net_out_speed, "+
"AVG(load1) AS load1, "+
"AVG(load5) AS load5, "+
"AVG(load15) AS load15, "+
"CAST(AVG(tcp_conns) AS INTEGER) AS tcp_conns, "+
"CAST(AVG(udp_conns) AS INTEGER) AS udp_conns, "+
"CAST(MAX(uptime) AS INTEGER) AS uptime",
bucketExpr,
),
nodeID,
).
Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Group(groupExpr).
Order("timestamp ASC").
Limit(targetPoints + 100). // safety margin
Scan(&metrics).Error
if metrics == nil {
metrics = make([]model.NodeMetric, 0)
}
return metrics, err
}
@@ -3421,10 +3468,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
@@ -3568,6 +3615,25 @@ func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]mod
return results, err
}
// GetServiceMonitorResultsByTimeRange returns results for a monitor within [startMs, endMs].
// Mirrors GetNodeMetrics / GetTunnelMetrics pattern for time-range based charting.
func (r *Repository) GetServiceMonitorResultsByTimeRange(monitorID int64, startMs, endMs int64) ([]model.ServiceMonitorResult, error) {
if r == nil || r.db == nil {
return nil, nil
}
var results []model.ServiceMonitorResult
err := r.db.Where("monitor_id = ? AND timestamp >= ? AND timestamp <= ?", monitorID, startMs, endMs).
Order("timestamp DESC").
Limit(5000).
Find(&results).Error
if len(results) > 1 {
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
}
return results, err
}
// GetLatestServiceMonitorResults returns the newest result per monitor_id.
// This is intended for list rendering (avoid N+1 queries).
func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) {
@@ -11,7 +11,8 @@ 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
return nodes, err
@@ -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)
}
+4 -1
View File
@@ -1,6 +1,9 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
+3 -1
View File
@@ -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)
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here. 30+ top-level packages - framework-style layout.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X API KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Gin-based management API for reading/writing config and controlling services at runtime.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X CONFIG KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
+4 -1
View File
@@ -1,6 +1,9 @@
# GOST CONNECTOR KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Connection initiators (clients) for various protocols in GOST forwarding.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X DIALERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Outbound dialers (client-side connection establishment) used by connectors/handlers.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X HANDLERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Protocol handlers (server-side request handling) used by services defined in the GOST config.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X LISTENERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
+4 -1
View File
@@ -1,6 +1,9 @@
# GO-GOST REGISTRY KNOWLEDGE BASE
**Generated:** Wed Feb 04 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
+4 -1
View File
@@ -1,6 +1,9 @@
# GOST SOCKET KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
WebSocket reporter and socket utilities for panel integration.
+21
View File
@@ -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
}
+2 -2
View File
@@ -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"}
+19
View File
@@ -0,0 +1,19 @@
# 013 - 隧道入口端口校验不严格修复
## Issue
- GitHub Issue: [#373](https://github.com/Sagit-chu/flvx/issues/373)
- 添加隧道入口时,端口校验不严格 - 默认端口可能不在新入口设置范围内
## 问题分析
当已有隧道上添加新入口节点时,系统会使用既有转发的端口部署到新入口节点上,
但该端口可能不在新入口节点设置的端口范围内,且没有校验提示。
### 根因
`syncTunnelForwardsEntryPorts` 函数在同步入口端口时,直接复用了原有端口,
没有检查该端口是否在新入口节点的允许范围内。
## 修复方案
- [x] 1. 新增 `isPortValidForAllEntryNodes` 辅助方法,校验端口是否在各节点范围内
- [x] 2. 在 `syncTunnelForwardsEntryPorts` 中检测端口超范围时,通过 `pickTunnelPort` 自动随机分配新端口
- [x] 3. 构建通过 + 全量测试通过
+16
View File
@@ -0,0 +1,16 @@
# Tunnel Quality Uptime Kuma Display
**Plan ID**: 056-tunnel-uptime-kuma-bars.md
## Objective
The goal is to modify the "Monitor - Tunnel" UI page to remove the isolated "Quality" column/chip, and replace the specific latency readouts for entry->exit and exit->Bing with an Uptime Kuma style row of visual history bars.
## Steps
- [x] Remove the individual "Quality" column from the tunnel monitor table view.
- [x] Remove the individual "Quality" chip from the tunnel monitor grid view.
- [x] Implement `<UptimeHistoryBar />` to display a historical sequence of up to 30 metrics, coloring by latency (success, warning, danger) and packet loss (danger).
- [x] Modify the front-end to poll/load initial tunnel quality history, effectively padding the history bars instead of them starting empty.
- [x] Update `getMonitorTunnelQuality` auto-polling locally to append strictly to the local history state, truncating appropriately.
- [x] Retain current latency display next to or below the Uptime bars to allow numerical visibility.
## Status
Completed. The interface will now load history for all displayed tunnels upon open, and continue tracking with bars appending in real time every 10-second polling interval.
+13
View File
@@ -0,0 +1,13 @@
# 057 Hide Remote Nodes from Monitor View
## Goal
Do not display remote nodes on the Monitoring page.
## Changes Made
- Modified `ListMonitorNodes` in `go-backend/internal/store/repo/repository_monitor_nodes.go` by adding a `.Where("is_remote = ?", 0)` constraint so that remote nodes (imported via Federation feature) are entirely excluded from the returned payload for API `/api/v1/monitor/nodes`.
- This efficiently removes remote nodes from both the grid/list displaying Node stats in the Monitoring tab and also eliminates remote nodes from the selection dropdown when creating new Service Monitors.
## Checklist
- [x] Identify how "remote node" is defined in the database structure (`IsRemote` = 1 or 0).
- [x] Add SQL query constraint to filter out remote nodes from the `/monitor/nodes` API response.
- [x] Verify changes compile successfully.
+13
View File
@@ -0,0 +1,13 @@
# Plan 058: Sync Main and Push Tag
Sync current changes to `main`, merge to `master` (if needed) or just PR/merge on `main` (if that's the flow), and push a new tag `2.1.9-beta7`.
## Tasks
- [x] Determine the next tag version (`2.1.9-beta7`).
- [x] Create a feature branch `release/2.1.9-beta7`.
- [x] Commit all current changes to the feature branch.
- [x] Push the feature branch to remote.
- [x] Create a PR to `main`.
- [x] Merge the PR to `main`.
- [x] Create and push tag `2.1.9-beta7`.
+59
View File
@@ -0,0 +1,59 @@
# 059 - 主题系统设计(v2 — 完整可扩展架构)
## 概述
设计一个高度可扩展的主题包架构,允许第三方作者通过代码提交的方式创建主题,覆盖前端所有元素——从 CSS 变量到组件实现、布局结构、甚至整个页面。
## 架构
```
src/themes/
├── types.ts # ThemePackage 接口定义
├── registry.ts # 主题注册表 + CSS 注入引擎
├── context.tsx # React Context + Provider + Hooks
├── index.ts # 公共 API barrel
├── loader.ts # 主题加载器(注册所有内置主题)
├── README.md # 主题开发指南
│
├── default/ # 默认主题(参考实现)
│ └── index.ts
│
├── example-cyberpunk/ # 示例主题(赛博朋克)
│ ├── index.ts
│ └── components/
│ └── button.tsx # 组件覆盖示范
│
└── <your-theme>/ # 第三方主题
├── index.ts
├── components/
├── layouts/
├── pages/
└── assets/
```
## 覆盖层级
| 层级 | 字段 | 说明 |
|------|------|------|
| CSS 变量 | `tokens.light` / `tokens.dark` | 80+ 个设计 token(颜色、字体、圆角) |
| 原始 CSS | `css` | 注入自定义 CSS(动画、字体、阴影等) |
| 组件替换 | `components` | 替换任意 UI 组件(30+ 个可替换组件键) |
| 布局替换 | `layouts` | 替换 4 种布局(Admin / H5 / H5Simple / Default) |
| 页面替换 | `pages` | 替换 14 个页面路由实现 |
| 生命周期 | `onActivate` / `onDeactivate` | 主题启用/停用回调 |
## 任务清单
- [x] **T1**: 创建 `src/themes/types.ts` — ThemePackage 接口 + 所有可覆盖键定义
- [x] **T2**: 创建 `src/themes/registry.ts` — 主题注册/激活/停用/CSS 注入引擎
- [x] **T3**: 创建 `src/themes/context.tsx` — React Context + ThemeProvider + hooks
- [x] **T4**: 创建 `src/themes/index.ts` — 公共 API barrel
- [x] **T5**: 创建 `src/themes/loader.ts` — 自动加载所有内置主题
- [x] **T6**: 创建 `src/themes/default/` — 默认主题参考实现
- [x] **T7**: 创建 `src/themes/example-cyberpunk/` — 完整示例主题(含组件覆盖 + CSS + 生命周期)
- [x] **T8**: 重构 `use-theme.tsx` — 向后兼容包装
- [x] **T9**: 重构 `theme-provider.tsx` — 集成新主题系统
- [x] **T10**: 编写 `README.md` — 主题开发完整指南
- [x] **T11**: TypeScript 编译验证通过
- [ ] **T12**: (后续) 设置页面集成主题选择器 UI
- [ ] **T13**: (后续) 将现有组件导入逐步迁移到 `useThemedComponent` 模式
+42
View File
@@ -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秒上报"
+15
View File
@@ -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`).
+49
View File
@@ -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
```
+12
View File
@@ -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`.
+12
View File
@@ -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`).
- [ ] Commit all changes to branch `chore/rc7-bump`.
- [ ] Push branch to remote.
- [ ] Create Pull Request using `gh`.
- [ ] Merge Pull Request using `gh`.
- [ ] Create and push tag `2.1.9-rc7`.
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -1,9 +1,9 @@
# VITE FRONTEND KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.5-rc15
**Tag:** 2.1.9-beta6
## OVERVIEW
Web management console for FLVX.
+20 -5
View File
@@ -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';
+15 -3
View File
@@ -427,6 +427,7 @@ export const getNodeMetrics = (
return Network.get<NodeMetricApiItem[]>(
`/monitor/nodes/${nodeId}/metrics`,
params,
{ timeout: 60_000 },
);
};
@@ -486,11 +487,22 @@ export const updateServiceMonitor = (data: ServiceMonitorMutationPayload) =>
export const deleteServiceMonitor = (id: number) =>
Network.post("/monitor/services/delete", { id });
export const getServiceMonitorResults = (monitorId: number, limit = 100) =>
Network.get<ServiceMonitorResultApiItem[]>(
export const getServiceMonitorResults = (
monitorId: number,
options?: { limit?: number; start?: number; end?: number },
) => {
const params: Record<string, string> = {};
if (options?.start != null && options?.end != null) {
params.start = String(options.start);
params.end = String(options.end);
} else if (options?.limit != null) {
params.limit = String(options.limit);
}
return Network.get<ServiceMonitorResultApiItem[]>(
`/monitor/services/${monitorId}/results`,
{ limit: String(limit) },
params,
);
};
export const getServiceMonitorLatestResults = () =>
Network.get<ServiceMonitorResultApiItem[]>(
+1
View File
@@ -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
+20 -43
View File
@@ -1,51 +1,28 @@
import React, { useEffect } from "react";
/**
* ThemeProvider — app-level wrapper
* =================================
* Loads all registered themes and wraps children with the theme context.
* Import the loader to ensure all built-in themes are registered before
* the provider mounts.
*/
import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
import React from "react";
// Side-effect: registers all built-in themes
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;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { theme, setTheme } = useTheme();
useEffect(() => {
// 确保主题与HTML class同步
const updateThemeClass = (currentTheme: string) => {
if (currentTheme === "dark") {
document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = "dark";
} else {
document.documentElement.classList.remove("dark");
document.documentElement.style.colorScheme = "light";
}
};
// 始终跟随系统主题
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
if (systemTheme !== theme) {
setTheme(systemTheme);
}
// 监听主题变化
updateThemeClass(theme);
// 监听系统主题变化
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleThemeChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? "dark" : "light";
setTheme(newTheme);
};
mediaQuery.addEventListener("change", handleThemeChange);
return () => mediaQuery.removeEventListener("change", handleThemeChange);
}, [theme, setTheme]);
return <>{children}</>;
return <ThemeContextProvider>{children}</ThemeContextProvider>;
};
@@ -0,0 +1,179 @@
/**
* ThemeSettings — theme picker card for the Settings page
* ========================================================
* Shows:
* • Mode toggle (light / dark / system)
* • Grid of registered themes with preview dots
* • "Reset to default" option
*/
import React from "react";
import toast from "react-hot-toast";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { useThemeContext } from "@/themes/context";
import type { ThemeMode } from "@/themes/registry";
// ─── Constants ──────────────────────────────────────────────────────────────
const MODE_OPTIONS: Array<{ value: ThemeMode; label: string; icon: string }> = [
{ value: "light", label: "亮色", icon: "☀️" },
{ value: "dark", label: "暗色", icon: "🌙" },
{ value: "system", label: "跟随系统", icon: "🖥️" },
];
// ─── Component ──────────────────────────────────────────────────────────────
export const ThemeSettings: React.FC = () => {
const {
themes,
activeThemeId,
mode,
effectiveMode,
switchTheme,
resetTheme,
setMode,
} = useThemeContext();
const handleModeChange = (m: ThemeMode) => {
setMode(m);
const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统";
toast.success(`已切换为${label}模式`);
};
const handleThemeSelect = (id: string) => {
switchTheme(id);
const theme = themes.find((t) => t.id === id);
toast.success(`已切换主题「${theme?.name ?? id}」`);
};
const handleReset = () => {
resetTheme();
toast.success("已恢复默认主题");
};
return (
<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-5">
主题设置
</h2>
{/* ── Mode toggle ────────────────────────────────────── */}
<div className="mb-6">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
外观模式
</p>
<div className="inline-flex rounded-lg border border-gray-200 dark:border-gray-600 p-1 gap-1">
{MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
mode === opt.value
? "bg-primary text-white shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700/50"
}`}
type="button"
onClick={() => handleModeChange(opt.value)}
>
<span className="mr-1.5">{opt.icon}</span>
{opt.label}
</button>
))}
</div>
</div>
{/* ── Theme grid ─────────────────────────────────────── */}
<div className="mb-4">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
选择主题
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">
共 {themes.length} 个可用主题
</span>
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{themes.map((theme) => {
const isActive = activeThemeId === theme.id;
// Pick the right token set for preview
const previewTokens =
effectiveMode === "dark" && theme.tokens?.dark
? theme.tokens.dark
: theme.tokens?.light;
const primary = previewTokens?.["--primary"] ?? "#2563eb";
const secondary = previewTokens?.["--secondary"] ?? "#6366f1";
const success = previewTokens?.["--success"] ?? "#16a34a";
const danger = previewTokens?.["--danger"] ?? "#dc2626";
const bg = previewTokens?.["--background"] ?? "#ffffff";
return (
<button
key={theme.id}
className={`relative rounded-xl text-left transition-all duration-200 border-2 overflow-hidden ${
isActive
? "border-primary shadow-lg shadow-primary/15 scale-[1.01]"
: "border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 hover:shadow-md"
}`}
type="button"
onClick={() => handleThemeSelect(theme.id)}
>
{/* Colour strip preview */}
<div className="flex h-8">
<div className="flex-1" style={{ background: primary }} />
<div className="flex-1" style={{ background: secondary }} />
<div className="flex-1" style={{ background: success }} />
<div className="flex-1" style={{ background: danger }} />
<div className="flex-1" style={{ background: bg, borderLeft: "1px solid rgba(0,0,0,0.06)" }} />
</div>
{/* Info */}
<div className="p-3">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-900 dark:text-white truncate">
{theme.name}
</p>
{isActive && (
<span className="shrink-0 px-1.5 py-0.5 rounded text-[10px] font-bold bg-primary/15 text-primary">
当前
</span>
)}
</div>
{theme.description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{theme.description}
</p>
)}
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">
{theme.author} · v{theme.version}
</p>
</div>
{/* Active indicator dot */}
{isActive && (
<span className="absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-primary shadow-sm shadow-primary/50 animate-pulse" />
)}
</button>
);
})}
</div>
</div>
{/* ── Reset ──────────────────────────────────────────── */}
{activeThemeId && activeThemeId !== "default" && (
<div className="pt-2">
<Button
className="text-gray-500 dark:text-gray-400"
size="sm"
variant="light"
onPress={handleReset}
>
↩ 恢复默认主题
</Button>
</div>
)}
</CardBody>
</Card>
);
};
+6
View File
@@ -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">
+89 -3
View File
@@ -18,6 +18,15 @@ import {
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import {
createSpeedLimit,
getSpeedLimitList,
@@ -51,6 +60,10 @@ export default function LimitPage() {
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"limit-view-mode",
"grid",
);
const filteredRules = useMemo(() => {
if (!searchKeyword.trim()) return rules;
@@ -231,13 +244,85 @@ export default function LimitPage() {
/>
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{/* 统一卡片网格 */}
{filteredRules.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
rule.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{rule.name}
</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm font-mono text-default-600">
{rule.speed} Mbps
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => (
<StaggerItem key={rule.id}>
@@ -324,6 +409,7 @@ export default function LimitPage() {
</StaggerItem>
))}
</StaggerList>
)
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
+2
View File
@@ -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]));
+13 -10
View File
@@ -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,
@@ -1407,13 +1409,6 @@ export default function NodePage() {
if (!nodeList || nodeList.length === 0) return [];
const sortedByDb = [...nodeList].sort((a, b) => {
const expiryDiff =
getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -
getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;
if (expiryDiff !== 0) {
return expiryDiff;
}
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
@@ -1768,7 +1763,15 @@ export default function NodePage() {
/>
) : viewMode === "list" ? (
<Card>
<Table aria-label="节点列表" className="overflow-x-auto min-w-full">
<Table
aria-label="节点列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox
@@ -1946,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">
+280 -228
View File
@@ -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,10 +269,147 @@ 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: 30,
minIntervalSec: 30,
defaultIntervalSec: 60,
checkerScanIntervalSec: 1,
minIntervalSec: 1,
defaultIntervalSec: 1,
minTimeoutSec: 1,
defaultTimeoutSec: 5,
maxTimeoutSec: 60,
@@ -314,7 +457,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
});
const [submitLoading, setSubmitLoading] = useState(false);
const [activeServiceMonitorId, setActiveServiceMonitorId] = useState<number | null>(null);
const [serviceMonitorRangeLimit, setServiceMonitorRangeLimit] = useState(50);
const [serviceMonitorRangeMs, setServiceMonitorRangeMs] = useState(60 * 60 * 1000);
const [accessDenied, setAccessDenied] = useState<string | null>(null);
const [resultsModalOpen, setResultsModalOpen] = useState(false);
@@ -386,27 +529,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",
@@ -465,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("加载指标失败");
@@ -526,9 +676,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
}, []);
const loadMonitorResults = useCallback(
async (monitorId: number, limit = 20) => {
async (monitorId: number, options?: { rangeMs?: number; limit?: number }) => {
try {
const response = await getServiceMonitorResults(monitorId, limit);
const apiOptions = options?.rangeMs != null
? { start: Date.now() - options.rangeMs, end: Date.now() }
: { limit: options?.limit ?? 100 };
const response = await getServiceMonitorResults(monitorId, apiOptions);
if (response.code === 0 && response.data) {
setMonitorResults((prev) => ({
@@ -600,7 +753,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
if (!resultsMonitorId) return;
setResultsLoading(true);
try {
await loadMonitorResults(resultsMonitorId, resultsLimit);
await loadMonitorResults(resultsMonitorId, { limit: resultsLimit });
} finally {
setResultsLoading(false);
}
@@ -616,7 +769,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const timer = window.setInterval(() => {
void loadServiceMonitors({ silent: true });
void loadLatestMonitorResults();
}, 30_000);
}, 5_000);
return () => window.clearInterval(timer);
}, [loadLatestMonitorResults, loadServiceMonitors]);
@@ -641,13 +794,27 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
void loadResultsForModal();
}, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]);
// Reload results for the active service monitor chart when range limit changes
// 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, serviceMonitorRangeLimit);
}, [activeServiceMonitorId, serviceMonitorRangeLimit, loadMonitorResults]);
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,
@@ -659,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]);
@@ -915,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) {
@@ -925,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;
},
@@ -966,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;
@@ -1065,7 +1153,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
</div>
) : (
<Card className="w-full">
<Table aria-label="节点列表">
<Table
aria-label="节点列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>状态</TableColumn>
<TableColumn>名称</TableColumn>
@@ -1084,30 +1180,45 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const isOnline = node.connectionStatus === "online";
return (
<TableRow key={node.id} className="border-b border-divider/50 last:border-b-0">
<TableRow key={node.id}>
<TableCell>
<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>
@@ -1223,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 */}
@@ -1314,10 +1361,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false;
const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : [];
const activeLatencyData = [...activeResults]
.slice(0, serviceMonitorRangeLimit)
.reverse()
.map((r) => ({
time: formatTimestamp(r.timestamp),
time: formatTimestamp(r.timestamp, serviceMonitorRangeMs),
latency: r.success === 1 ? r.latencyMs : null,
success: r.success,
}));
@@ -1328,24 +1373,23 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<h3 className="text-lg font-semibold">服务监控图表</h3>
<div className="flex items-center gap-2">
<Select
className="w-28"
selectedKeys={[String(serviceMonitorRangeLimit)]}
className="w-36"
selectedKeys={[String(serviceMonitorRangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) setServiceMonitorRangeLimit(v);
if (v > 0) setServiceMonitorRangeMs(v);
}}
>
<SelectItem key="20">20条</SelectItem>
<SelectItem key="50">50条</SelectItem>
<SelectItem key="100">100条</SelectItem>
<SelectItem key="200">200条</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>
{resolvedActiveMonitorId != null && (
<Button
size="sm"
variant="flat"
onPress={() => {
void loadMonitorResults(resolvedActiveMonitorId, serviceMonitorRangeLimit);
void loadMonitorResults(resolvedActiveMonitorId, { rangeMs: serviceMonitorRangeMs });
}}
>
<RefreshCw className="w-4 h-4 mr-1" />
@@ -1383,7 +1427,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
setActiveServiceMonitorId(monitor.id);
// Load results for this monitor if not loaded
if (!monitorResults[monitor.id] || monitorResults[monitor.id].length <= 1) {
void loadMonitorResults(monitor.id, serviceMonitorRangeLimit);
void loadMonitorResults(monitor.id, { rangeMs: serviceMonitorRangeMs });
}
}}
>
@@ -1404,7 +1448,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}
@@ -1536,7 +1580,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<RefreshCw className="w-6 h-6 animate-spin" />
</div>
) : modalResults.length > 0 ? (
<Table aria-label="监控记录" className="w-full">
<Table
aria-label="监控记录"
className="w-full overflow-x-auto"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>时间</TableColumn>
<TableColumn>结果</TableColumn>
+37 -1
View File
@@ -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;
}
@@ -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,
@@ -32,7 +32,7 @@ import {
getMonitorTunnelQuality,
getMonitorTunnelQualityHistory,
} from "@/api";
import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Chip } from "@/shadcn-bridge/heroui/chip";
@@ -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);
@@ -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 }) {
@@ -109,6 +100,234 @@ function LiveDot() {
);
}
const UPTIME_KUMA_BARS_COUNT = 30;
function UptimeHistoryBar({
history = [],
type,
latestValue,
}: {
history: TunnelQualityApiItem[];
type: "entryToExit" | "exitToBing";
latestValue?: number;
}) {
const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => {
const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i;
if (historyIndex >= 0 && historyIndex < history.length) {
return history[historyIndex];
}
return null;
});
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-[3px]">
{bars.map((q, idx) => {
if (!q) {
return (
<div
key={idx}
className="w-1.5 h-4 rounded-full bg-default-200 dark:bg-default-100/50"
title="暂无数据"
/>
);
}
const isEntry = type === "entryToExit";
const latency = isEntry ? q.entryToExitLatency : q.exitToBingLatency;
const loss = isEntry ? q.entryToExitLoss : q.exitToBingLoss;
const timeStr = new Date(q.timestamp).toLocaleTimeString("zh-CN");
let colorClass = "bg-success";
let statusText = "优秀";
if (q.errorMessage || (loss !== undefined && loss > 0)) {
colorClass = "bg-danger";
statusText = q.errorMessage ? "错误" : `丢包 ${loss?.toFixed(1)}%`;
} else if (latency < 0) {
colorClass = "bg-danger";
statusText = "探测失败";
} else if (latency > 200) {
colorClass = "bg-danger";
statusText = "极差";
} else if (latency > 100) {
colorClass = "bg-warning";
statusText = "较差";
}
const displayLatency = latency >= 0 ? `${latency.toFixed(0)}ms` : "-";
const tooltip = `${timeStr} | ${displayLatency} | ${statusText}`;
return (
<div
key={q.timestamp || idx}
className={`w-1.5 h-4 rounded-full transition-colors cursor-help hover:opacity-80 ${colorClass}`}
title={tooltip}
/>
);
})}
</div>
<div>
<LatencyDisplay value={latestValue} />
</div>
</div>
);
}
/* ─── 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);
@@ -117,6 +336,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
// Quality data from backend periodic probing (latest per tunnel)
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
const initialHistoryFetched = useRef(false);
const [qualityLoading, setQualityLoading] = useState(false);
const qualityTimerRef = useRef<number | null>(null);
@@ -178,6 +399,45 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => window.clearInterval(timer);
}, [loadTunnels]);
// --- Initial history load ---
useEffect(() => {
if (tunnels.length > 0 && !initialHistoryFetched.current) {
initialHistoryFetched.current = true;
const fetchHistory = async () => {
const end = Date.now();
// Fetch last 5 minutes (about 30 points)
const start = end - 5 * 60 * 1000;
const promises = tunnels.map((t) =>
getMonitorTunnelQualityHistory(t.id, start, end).then(res => {
if (res.code === 0 && res.data) {
return { id: t.id, data: res.data };
}
return { id: t.id, data: [] };
}).catch(() => ({ id: t.id, data: [] }))
);
const results = await Promise.all(promises);
setQualityHistoryMap(prev => {
const nextMap = { ...prev };
results.forEach(r => {
const existing = nextMap[r.id] || [];
const merged = [...r.data, ...existing].sort((a,b) => a.timestamp - b.timestamp);
const unique: TunnelQualityApiItem[] = [];
for (const item of merged) {
if (unique.length === 0 || unique[unique.length-1].timestamp !== item.timestamp) {
unique.push(item);
}
}
nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT);
});
return nextMap;
});
};
void fetchHistory();
}
}, [tunnels]);
// --- Load quality snapshots (auto-polling every 10s) ---
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
@@ -190,6 +450,19 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
map[q.tunnelId] = q;
}
setQualityMap(map);
// Accumulate locally
setQualityHistoryMap(prev => {
const nextMap = { ...prev };
for (const q of response.data) {
const currentArr = nextMap[q.tunnelId] || [];
const lastItem = currentArr.length > 0 ? currentArr[currentArr.length - 1] : null;
if (!lastItem || lastItem.timestamp !== q.timestamp) {
nextMap[q.tunnelId] = [...currentArr, q].slice(-UPTIME_KUMA_BARS_COUNT);
}
}
return nextMap;
});
}
} catch {
// Silently ignore quality load failures
@@ -292,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)
@@ -341,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
// =====================
@@ -445,7 +694,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")}
@@ -456,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>
);
}
@@ -585,7 +738,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">
@@ -609,9 +762,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{tunnels.map((tunnel) => {
const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1;
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
: null;
return (
<Card
@@ -637,11 +787,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
</div>
</div>
</div>
{overallQuality && (
<Chip size="sm" color={overallQuality.color} variant="flat">
{overallQuality.text}
</Chip>
)}
</CardHeader>
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden">
@@ -651,14 +796,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<Zap className="w-3 h-3" />
入口→出口
</div>
<LatencyDisplay value={quality?.entryToExitLatency} />
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
</div>
<div className="space-y-1">
<div className="text-[10px] text-default-500 flex items-center gap-1">
<Globe className="w-3 h-3" />
出口→Bing
</div>
<LatencyDisplay value={quality?.exitToBingLatency} />
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
</div>
</div>
@@ -681,25 +826,29 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
</div>
) : (
<Card className="w-full">
<Table aria-label="隧道列表">
<Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>状态</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>入口→出口</TableColumn>
<TableColumn>出口→Bing</TableColumn>
<TableColumn>质量</TableColumn>
<TableColumn>更新时间</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道">
{tunnels.map((tunnel) => {
const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1;
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
: null;
return (
<TableRow key={tunnel.id} className="border-b border-divider/50 last:border-b-0 cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
<TableCell>
<div className="flex items-center gap-1.5">
{isEnabled ? (
@@ -713,19 +862,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
</TableCell>
<TableCell>
<LatencyDisplay value={quality?.entryToExitLatency} />
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
</TableCell>
<TableCell>
<LatencyDisplay value={quality?.exitToBingLatency} />
</TableCell>
<TableCell>
{overallQuality ? (
<Chip size="sm" color={overallQuality.color} variant="flat">
{overallQuality.text}
</Chip>
) : (
<span className="text-xs text-default-400">-</span>
)}
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
</TableCell>
<TableCell>
{quality?.timestamp ? (
+147
View File
@@ -45,6 +45,15 @@ import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import {
createTunnel,
batchDeleteTunnelsWithForwards,
@@ -181,6 +190,10 @@ export default function TunnelPage() {
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"tunnel-view-mode",
"grid",
);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
@@ -1523,6 +1536,14 @@ export default function TunnelPage() {
>
批量
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</Button>
<Button
color="primary"
size="sm"
@@ -1556,6 +1577,131 @@ export default function TunnelPage() {
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
{selectMode ? <TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox
isSelected={selectedIds.size === sortedTunnels.length && sortedTunnels.length > 0}
onValueChange={(checked) => checked ? selectAll() : deselectAll()}
/>
</TableColumn> : <TableColumn className="w-0 p-0 overflow-hidden text-[0px]"></TableColumn>}
<TableColumn>隧道名称</TableColumn>
<TableColumn>类型</TableColumn>
<TableColumn>拓扑</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={sortedTunnels}>
{(tunnel) => {
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
const tunnelTypeChipClassName =
tunnel.type === 1
? "text-[10px] h-5 bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
: "text-[10px] h-5 bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
return (
<TableRow key={tunnel.id}>
{selectMode ? <TableCell className="px-4">
<Checkbox
isSelected={selectedIds.has(tunnel.id)}
onValueChange={() => toggleSelect(tunnel.id)}
/>
</TableCell> : <TableCell className="w-0 p-0 overflow-hidden text-[0px]"></TableCell>}
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
tunnel.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{tunnel.name}
</span>
</div>
</TableCell>
<TableCell>
<Chip
className={tunnelTypeChipClassName}
color={typeDisplay.color as any}
size="sm"
variant="flat"
>
{typeDisplay.text}
</Chip>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-xs">
<span className="font-semibold text-primary-700 dark:text-primary-400">
{tunnel.inNodeId?.length || 0}入口
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
{tunnel.type === 2 ? tunnel.chainNodes?.length || 0 : 0}跳
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-success-700 dark:text-success-400">
{tunnel.type === 2 ? tunnel.outNodeId?.length || 0 : tunnel.inNodeId?.length || 0}出口
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-xs">
<span className="text-default-500">{getTunnelFlowDisplay(tunnel.flow)}</span>
<span className="text-default-300">|</span>
<span className="text-default-500">{tunnel.trafficRatio}x</span>
{tunnel.type === 2 && tunnel.ipPreference && (
<>
<span className="text-default-300">|</span>
<span className="text-default-500">{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}</span>
</>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(tunnel)}
>
编辑
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
size="sm"
variant="flat"
onPress={() => handleDiagnose(tunnel)}
>
诊断
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
onPress={() => handleDelete(tunnel)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</Card>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={sortableTunnelIds}
@@ -1836,6 +1982,7 @@ export default function TunnelPage() {
</div>
</SortableContext>
</DndContext>
)
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
+12 -3
View File
@@ -1080,7 +1080,15 @@ export default function UserPage() {
</Card>
) : viewMode === "list" ? (
<Card>
<Table aria-label="用户列表" className="overflow-x-auto min-w-full">
<Table
aria-label="用户列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>用户名</TableColumn>
<TableColumn>流量统计</TableColumn>
@@ -1888,8 +1896,9 @@ export default function UserPage() {
<Table
aria-label="用户隧道权限列表"
classNames={{
wrapper: "shadow-none",
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
@@ -1,62 +1,43 @@
import * as React from "react";
/**
* useTheme — backwards-compatible hook
* =====================================
* Wraps the new theme system's context to provide the same API that the
* rest of the codebase already expects: `{ theme, setTheme }`.
*
* For full theme system access, use `useThemeContext` from "@/themes".
*/
type ThemeMode = "light" | "dark";
import { useSyncExternalStore, useCallback } from "react";
const STORAGE_KEY = "flvx:theme";
import {
subscribe,
getSavedMode,
getEffectiveMode,
saveMode,
reapplyActiveTheme,
type ThemeMode,
} from "@/themes/registry";
function resolveInitialTheme(): ThemeMode {
if (typeof window === "undefined") {
return "light";
}
const fromStorage = window.localStorage.getItem(STORAGE_KEY);
if (fromStorage === "dark" || fromStorage === "light") {
return fromStorage;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
let currentTheme: ThemeMode = resolveInitialTheme();
const listeners = new Set<(theme: ThemeMode) => void>();
function broadcast(theme: ThemeMode) {
currentTheme = theme;
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, theme);
}
listeners.forEach((listener) => {
listener(theme);
// Monotonic counter for snapshot identity
let _rev = 0;
const _sub = (cb: () => void) =>
subscribe(() => {
_rev++;
cb();
});
}
const _snap = () => _rev;
export function useTheme() {
const [theme, setThemeState] = React.useState<ThemeMode>(currentTheme);
useSyncExternalStore(_sub, _snap);
React.useEffect(() => {
const listener = (nextTheme: ThemeMode) => {
setThemeState(nextTheme);
};
const theme = getEffectiveMode();
const mode = getSavedMode();
listeners.add(listener);
return () => {
listeners.delete(listener);
};
const setTheme = useCallback((next: string) => {
if (next !== "dark" && next !== "light" && next !== "system") return;
saveMode(next as ThemeMode);
reapplyActiveTheme();
}, []);
const setTheme = React.useCallback((nextTheme: string) => {
if (nextTheme !== "dark" && nextTheme !== "light") {
return;
}
broadcast(nextTheme);
}, []);
return {
setTheme,
theme,
};
return { theme, mode, setTheme };
}
+382
View File
@@ -0,0 +1,382 @@
# FLVX 主题开发指南
## 概述
FLVX 主题系统允许你完全自定义前端的外观和行为。一个主题可以覆盖:
| 覆盖层级 | 说明 | 难度 |
|----------|------|------|
| **CSS 变量** | 修改颜色、字体、圆角等设计 token | ⭐ 简单 |
| **原始 CSS** | 注入自定义 CSS(动画、字体、阴影等) | ⭐⭐ 中等 |
| **组件替换** | 替换任意 UI 组件(按钮、卡片、输入框等) | ⭐⭐⭐ 高级 |
| **布局替换** | 替换整个页面布局结构 | ⭐⭐⭐ 高级 |
| **页面替换** | 替换整个页面实现 | ⭐⭐⭐⭐ 专家 |
## 快速开始
### 1. 创建主题文件夹
```
src/themes/my-theme/
├── index.ts ← 必须:主题入口,导出 ThemePackage
├── components/ ← 可选:组件覆盖
│ ├── index.ts
│ └── button.tsx
├── layouts/ ← 可选:布局覆盖
│ └── admin.tsx
├── pages/ ← 可选:页面覆盖
│ └── login.tsx
├── assets/ ← 可选:图片、字体等资源
└── styles.css ← 可选:额外样式文件
```
### 2. 编写主题入口 `index.ts`
```typescript
import type { ThemePackage } from "../types";
const myTheme: ThemePackage = {
id: "my-theme", // 唯一标识(kebab-case)
name: "我的主题", // 显示名称
author: "Your Name", // 作者
version: "1.0.0", // 版本号
description: "一个自定义主题",
// CSS 变量覆盖
tokens: {
light: {
"--primary": "#ff6600",
"--primary-foreground": "#ffffff",
"--background": "#fafafa",
},
dark: {
"--primary": "#ff8833",
"--background": "#1a1a2e",
},
},
};
export default myTheme;
```
### 3. 注册主题
打开 `src/themes/loader.ts`,添加两行:
```typescript
import myTheme from "./my-theme";
registerTheme(myTheme);
```
完成!主题已可用。
---
## 详细指南
### CSS 变量覆盖
所有可用的 CSS 变量定义在 `src/themes/types.ts` 的 `ThemeTokens` 接口中。常用的:
```typescript
tokens: {
light: {
// 基础色
"--background": "#ffffff", // 页面背景
"--foreground": "#000000", // 文字颜色
"--border": "#e5e7eb", // 边框颜色
"--content1": "#ffffff", // 卡片背景
// 品牌色
"--primary": "#2563eb", // 主色
"--primary-foreground": "#fff", // 主色上的文字
"--secondary": "#6366f1", // 辅色
// 状态色
"--danger": "#dc2626",
"--success": "#16a34a",
"--warning": "#d97706",
// 每种品牌色都有 50-900 共 10 级色阶
"--primary-50": "#eff6ff", // 最浅
"--primary-500": "#3b82f6", // 中间
"--primary-900": "#1e3a8a", // 最深
// 字体
"--font-sans": '"Inter", sans-serif',
"--font-mono": '"Fira Code", monospace',
// 圆角
"--radius": "0.5rem",
},
dark: {
// 暗色模式下的覆盖...
},
}
```
> **提示**: 你不需要定义所有变量,只定义你想修改的,其余沿用默认值。
### 原始 CSS 注入
`css` 字段可以注入任意 CSS。主题激活时会插入一个 `<style>` 标签,停用时自动移除。
```typescript
const theme: ThemePackage = {
// ...
css: `
/* 自定义字体 */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC&display=swap');
body {
font-family: 'Noto Sans SC', sans-serif;
}
/* 自定义动画 */
@keyframes my-fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 给所有卡片加阴影 */
.rounded-xl, .rounded-lg {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
/* 自定义滚动条 */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 4px;
}
`,
};
```
### 组件替换
可以替换任意 UI 组件。替换组件**必须接受与原组件相同的 props**。
#### 可替换的组件列表
| 组件键名 | 原始位置 | 说明 |
|----------|----------|------|
| `Button` | `shadcn-bridge/heroui/button` | 按钮 |
| `Card`, `CardHeader`, `CardBody`, `CardFooter` | `shadcn-bridge/heroui/card` | 卡片 |
| `Input` | `shadcn-bridge/heroui/input` | 输入框 |
| `Select`, `SelectItem` | `shadcn-bridge/heroui/select` | 下拉选择 |
| `Switch` | `shadcn-bridge/heroui/switch` | 开关 |
| `Checkbox` | `shadcn-bridge/heroui/checkbox` | 复选框 |
| `Chip` | `shadcn-bridge/heroui/chip` | 标签/芯片 |
| `Modal`, `ModalContent`, `ModalHeader`, `ModalBody`, `ModalFooter` | `shadcn-bridge/heroui/modal` | 模态框 |
| `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableCell`, `TableColumn` | `shadcn-bridge/heroui/table` | 表格 |
| `Tabs`, `Tab` | `shadcn-bridge/heroui/tabs` | 标签页 |
| `Progress` | `shadcn-bridge/heroui/progress` | 进度条 |
| `Spinner` | `shadcn-bridge/heroui/spinner` | 加载指示器 |
| `Divider` | `shadcn-bridge/heroui/divider` | 分割线 |
| `Link` | `shadcn-bridge/heroui/link` | 链接 |
| `Dropdown`, `DropdownTrigger`, `DropdownMenu`, `DropdownItem` | `shadcn-bridge/heroui/dropdown` | 下拉菜单 |
| `Navbar`, `NavbarContent`, `NavbarItem` | `shadcn-bridge/heroui/navbar` | 导航栏 |
| `Radio`, `RadioGroup` | `shadcn-bridge/heroui/radio` | 单选框 |
| `Accordion`, `AccordionItem` | `shadcn-bridge/heroui/accordion` | 手风琴 |
| `DatePicker` | `shadcn-bridge/heroui/date-picker` | 日期选择器 |
| `Alert` | `shadcn-bridge/heroui/alert` | 警告提示 |
| `SearchBar` | `components/search-bar` | 搜索栏 |
| `BrandLogo` | `components/brand-logo` | 品牌 Logo |
| `VersionFooter` | `components/version-footer` | 版本页脚 |
#### 组件替换示例
**方式一:包装原组件**(推荐,保证兼容性)
```typescript
// src/themes/my-theme/components/button.tsx
import React from "react";
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
export const MyButton: React.FC<ButtonProps> = (props) => {
return (
<OriginalButton
{...props}
className={`${props.className || ""} my-custom-class`}
style={{
...props.style,
borderRadius: "9999px", // 全圆角
}}
/>
);
};
```
**方式二:完全重写组件**
```typescript
// src/themes/my-theme/components/button.tsx
import React from "react";
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
export const MyButton: React.FC<ButtonProps> = ({
children,
color = "default",
variant = "solid",
size = "md",
isLoading,
isDisabled,
onPress,
className,
...rest
}) => {
return (
<button
className={`my-totally-custom-button ${className || ""}`}
disabled={isDisabled || isLoading}
onClick={() => onPress?.()}
{...rest}
>
{isLoading && <span className="spinner" />}
{children}
</button>
);
};
```
然后在主题入口中注册:
```typescript
// src/themes/my-theme/index.ts
import { MyButton } from "./components/button";
const theme: ThemePackage = {
// ...
components: {
Button: MyButton,
},
};
```
### 布局替换
可以替换 4 种布局:
| 布局键名 | 说明 |
|----------|------|
| `AdminLayout` | 管理后台主布局(侧边栏 + 顶栏) |
| `H5Layout` | 移动端布局(底部导航) |
| `H5SimpleLayout` | 移动端简洁布局(无底部导航) |
| `DefaultLayout` | 默认布局(登录页等) |
```typescript
// src/themes/my-theme/layouts/admin.tsx
import React from "react";
const MyAdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<div className="my-admin-layout">
<header className="my-header">
{/* 自定义顶栏 */}
</header>
<aside className="my-sidebar">
{/* 自定义侧边栏 */}
</aside>
<main className="my-content">
{children}
</main>
</div>
);
};
export default MyAdminLayout;
```
### 页面替换
可以替换任意页面路由的实现:
| 页面键名 | 路由 |
|----------|------|
| `LoginPage` | `/` |
| `DashboardPage` | `/dashboard` |
| `MonitorPage` | `/monitor` |
| `ForwardPage` | `/forward` |
| `TunnelPage` | `/tunnel` |
| `NodePage` | `/node` |
| `UserPage` | `/user` |
| `GroupPage` | `/group` |
| `ProfilePage` | `/profile` |
| `LimitPage` | `/limit` |
| `ConfigPage` | `/config` |
| `PanelSharingPage` | `/panel-sharing` |
| `SettingsPage` | `/settings` |
### 生命周期钩子
```typescript
const theme: ThemePackage = {
// ...
onActivate: () => {
// 主题被激活时执行
// 例如:加载外部字体、注入全局属性
console.log("Theme activated!");
},
onDeactivate: () => {
// 主题被停用时执行
// 例如:清理全局属性
console.log("Theme deactivated!");
},
};
```
---
## 主题提交流程
1. Fork 本仓库
2. 在 `src/themes/` 下创建你的主题文件夹
3. 在 `src/themes/loader.ts` 中注册
4. 提交 Pull Request
### 命名规范
- 文件夹名:`kebab-case`(如 `my-awesome-theme`)
- 主题 `id`:与文件夹名一致
- 主题 `name`:简短中文名
### 代码规范
- TypeScript 严格模式
- 组件替换必须保证 props 兼容性
- 不得修改 `src/themes/types.ts`(影响其他主题)
- 不得修改 `src/themes/registry.ts`(影响核心逻辑)
- 仅修改你自己的主题文件夹 + `loader.ts` 中的注册
---
## 文件结构参考
```
src/themes/
├── types.ts # 主题接口定义 (勿改)
├── registry.ts # 主题注册表 (勿改)
├── context.tsx # React Context (勿改)
├── index.ts # 公共 API (勿改)
├── loader.ts # 主题加载器 (仅在此添加注册)
│
├── default/ # 默认主题 (参考实现)
│ └── index.ts
│
├── example-cyberpunk/ # 示例主题 (可复制修改)
│ ├── index.ts
│ └── components/
│ └── button.tsx
│
└── your-theme/ # 你的主题
├── index.ts
├── components/
│ ├── button.tsx
│ └── card.tsx
├── layouts/
│ └── admin.tsx
└── assets/
└── logo.svg
```
+188
View File
@@ -0,0 +1,188 @@
/**
* Theme Context + Provider (React integration)
* =============================================
* Wraps the registry in a React context so that the entire component tree
* re-renders when the active theme changes.
*/
import React, {
createContext,
useContext,
useEffect,
useSyncExternalStore,
useCallback,
useMemo,
} from "react";
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
import {
subscribe,
getActiveTheme,
getActiveThemeId,
getRegisteredThemes,
activateTheme,
deactivateTheme,
reapplyActiveTheme,
resolveComponent,
resolveLayout,
resolvePage,
getSavedMode,
saveMode,
getEffectiveMode,
registerTheme,
unregisterTheme,
type ThemeMode,
} from "./registry";
// ─── context value ───────────────────────────────────────────────────────────
interface ThemeContextValue {
/** Currently active theme package (null = default). */
activeTheme: ThemePackage | null;
activeThemeId: string | null;
/** All registered themes. */
themes: ThemePackage[];
/** Current mode preference. */
mode: ThemeMode;
/** Resolved effective mode (never "system"). */
effectiveMode: "light" | "dark";
/** Switch the active theme. */
switchTheme: (id: string) => void;
/** Reset to no custom theme (use defaults). */
resetTheme: () => void;
/** Change mode preference. */
setMode: (mode: ThemeMode) => void;
/** Register a new theme at runtime. */
register: (pkg: ThemePackage) => void;
/** Unregister a theme by id. */
unregister: (id: string) => void;
/** Resolve a component (returns themed override or fallback). */
component: <P = any>(key: ComponentKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
/** Resolve a layout. */
layout: (key: LayoutKey, fallback: React.ComponentType<{ children: React.ReactNode }>) => React.ComponentType<{ children: React.ReactNode }>;
/** Resolve a page. */
page: <P = any>(key: PageKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
// ─── snapshot for useSyncExternalStore ────────────────────────────────────────
// We use a monotonic counter to create new snapshot references when the
// registry notifies.
let snapshotCounter = 0;
function getSnapshot() {
return snapshotCounter;
}
const originalSubscribe = (onStoreChange: () => void) => {
const unsub = subscribe(() => {
snapshotCounter++;
onStoreChange();
});
return unsub;
};
// ─── provider ────────────────────────────────────────────────────────────────
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// Re-render whenever register changes
useSyncExternalStore(originalSubscribe, getSnapshot);
// Listen for system mode changes
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => reapplyActiveTheme();
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const switchTheme = useCallback((id: string) => activateTheme(id), []);
const resetTheme = useCallback(() => {
deactivateTheme();
reapplyActiveTheme();
localStorage.removeItem("flvx:active-theme");
}, []);
const setMode = useCallback((m: ThemeMode) => {
saveMode(m);
reapplyActiveTheme();
}, []);
const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []);
const unregister = useCallback((id: string) => unregisterTheme(id), []);
const value = useMemo<ThemeContextValue>(() => ({
activeTheme: getActiveTheme(),
activeThemeId: getActiveThemeId(),
themes: getRegisteredThemes(),
mode: getSavedMode(),
effectiveMode: getEffectiveMode(),
switchTheme,
resetTheme,
setMode,
register,
unregister,
component: resolveComponent,
layout: resolveLayout,
page: resolvePage,
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [snapshotCounter, switchTheme, resetTheme, setMode, register, unregister]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
// ─── hooks ───────────────────────────────────────────────────────────────────
/** Access the full theme context. */
export function useThemeContext(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useThemeContext must be used within <ThemeProvider>");
return ctx;
}
/**
* Convenience: resolve a single themed component.
*
* ```tsx
* import { useThemedComponent } from "@/themes/context";
* import { Button as DefaultButton } from "@/shadcn-bridge/heroui/button";
*
* function MyPage() {
* const Button = useThemedComponent("Button", DefaultButton);
* return <Button color="primary">Click</Button>;
* }
* ```
*/
export function useThemedComponent<P = any>(
key: ComponentKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const ctx = useThemeContext();
return ctx.component(key, fallback);
}
/** Convenience: resolve a themed layout. */
export function useThemedLayout(
key: LayoutKey,
fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> {
const ctx = useThemeContext();
return ctx.layout(key, fallback);
}
/** Convenience: resolve a themed page. */
export function useThemedPage<P = any>(
key: PageKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const ctx = useThemeContext();
return ctx.page(key, fallback);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Default Theme
* =============
* This is the built-in "stock" FLVX theme. It doesn't override any
* components — it only declares the CSS tokens that match the colours
* already defined in `globals.css`. This serves as the **reference
* implementation** that theme authors can copy and modify.
*
* When this theme is active, the frontend looks identical to an
* unmodified FLVX install.
*/
import type { ThemePackage } from "../types";
const defaultTheme: ThemePackage = {
id: "default",
name: "默认主题",
author: "FLVX Team",
version: "1.0.0",
description: "FLVX 内置默认蓝色主题",
tokens: {
light: {
"--background": "#f6f7fb",
"--foreground": "#111827",
"--border": "#e5e7eb",
"--input": "#d1d5db",
"--ring": "#93c5fd",
"--content1": "#ffffff",
"--divider": "#e5e7eb",
"--default-50": "#f9fafb",
"--default-100": "#f3f4f6",
"--default-200": "#e5e7eb",
"--default-300": "#d1d5db",
"--default-400": "#9ca3af",
"--default-500": "#6b7280",
"--default-600": "#4b5563",
"--default-700": "#374151",
"--default-800": "#1f2937",
"--default-900": "#111827",
"--primary": "#2563eb",
"--primary-foreground": "#ffffff",
"--primary-50": "#eff6ff",
"--primary-100": "#dbeafe",
"--primary-200": "#bfdbfe",
"--primary-300": "#93c5fd",
"--primary-400": "#60a5fa",
"--primary-500": "#3b82f6",
"--primary-600": "#2563eb",
"--primary-700": "#1d4ed8",
"--primary-800": "#1e40af",
"--primary-900": "#1e3a8a",
"--secondary": "#6366f1",
"--secondary-foreground": "#ffffff",
"--secondary-50": "#eef2ff",
"--secondary-100": "#e0e7ff",
"--secondary-200": "#c7d2fe",
"--secondary-300": "#a5b4fc",
"--secondary-400": "#818cf8",
"--secondary-500": "#6366f1",
"--secondary-600": "#4f46e5",
"--secondary-700": "#4338ca",
"--secondary-800": "#3730a3",
"--secondary-900": "#312e81",
"--danger": "#dc2626",
"--danger-50": "#fef2f2",
"--danger-100": "#fee2e2",
"--danger-200": "#fecaca",
"--danger-300": "#fca5a5",
"--danger-400": "#f87171",
"--danger-500": "#ef4444",
"--danger-600": "#dc2626",
"--danger-700": "#b91c1c",
"--danger-800": "#991b1b",
"--danger-900": "#7f1d1d",
"--success": "#16a34a",
"--success-50": "#f0fdf4",
"--success-100": "#dcfce7",
"--success-200": "#bbf7d0",
"--success-300": "#86efac",
"--success-400": "#4ade80",
"--success-500": "#22c55e",
"--success-600": "#16a34a",
"--success-700": "#15803d",
"--success-800": "#166534",
"--success-900": "#14532d",
"--warning": "#d97706",
"--warning-50": "#fffbeb",
"--warning-100": "#fef3c7",
"--warning-200": "#fde68a",
"--warning-300": "#fcd34d",
"--warning-400": "#fbbf24",
"--warning-500": "#f59e0b",
"--warning-600": "#d97706",
"--warning-700": "#b45309",
"--warning-800": "#92400e",
"--warning-900": "#78350f",
},
dark: {
"--background": "#0b1020",
"--foreground": "#f3f4f6",
"--border": "#334155",
"--input": "#475569",
"--ring": "#60a5fa",
"--content1": "#111827",
"--divider": "#334155",
"--default-50": "#0f172a",
"--default-100": "#1e293b",
"--default-200": "#334155",
"--default-300": "#475569",
"--default-400": "#64748b",
"--default-500": "#94a3b8",
"--default-600": "#cbd5e1",
"--default-700": "#e2e8f0",
"--default-800": "#f1f5f9",
"--default-900": "#f8fafc",
"--primary": "#3b82f6",
"--secondary": "#818cf8",
"--danger": "#ef4444",
"--success": "#22c55e",
"--warning": "#f59e0b",
},
},
// No component/layout/page overrides — uses all defaults.
};
export default defaultTheme;
@@ -0,0 +1,55 @@
/**
* Cyberpunk Button — Component Override Example
* ==============================================
* Demonstrates how to override a built-in component.
*
* Rules:
* 1. Accept the SAME props as the original component.
* 2. Import the original's props type for compatibility.
* 3. You CAN wrap the original component and add extra behaviour,
* or you can build a completely new component from scratch.
*/
import React from "react";
// Import the original Button's props interface for compatibility
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
// Optionally import the original to wrap it
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
/**
* CyberpunkButton wraps the original Button and adds a neon glow effect.
* It passes all props through, so it's a full drop-in replacement.
*/
export const CyberpunkButton: React.FC<ButtonProps> = (props) => {
const { className = "", style, color, ...rest } = props;
// Add neon glow based on color
const glowColor =
color === "danger"
? "rgba(255, 51, 102, 0.5)"
: color === "success"
? "rgba(0, 255, 136, 0.5)"
: color === "warning"
? "rgba(255, 170, 0, 0.5)"
: color === "secondary"
? "rgba(0, 255, 255, 0.5)"
: "rgba(255, 0, 255, 0.5)";
const glowStyle: React.CSSProperties = {
...style,
boxShadow: `0 0 8px ${glowColor}, 0 0 16px ${glowColor}`,
transition: "box-shadow 0.3s ease, transform 0.15s ease",
textTransform: "uppercase" as const,
letterSpacing: "0.05em",
};
return (
<OriginalButton
className={`${className} cyberpunk-btn`}
color={color}
style={glowStyle}
{...rest}
/>
);
};
@@ -0,0 +1,133 @@
/**
* Example Theme: Cyberpunk
* ========================
* A neon-cyberpunk dark theme that demonstrates ALL override capabilities:
* ✅ CSS tokens (full dark palette)
* ✅ Raw CSS (neon glow effects, custom animations, font-face)
* ✅ Component override (custom Button with glow)
* ✅ Lifecycle hooks
*
* Theme authors: copy this entire folder and modify it to create your own
* theme. See README.md for the full guide.
*/
import type { ThemePackage } from "../types";
import { CyberpunkButton } from "./components/button";
const cyberpunkTheme: ThemePackage = {
id: "cyberpunk",
name: "赛博朋克",
author: "FLVX Community",
version: "1.0.0",
description: "霓虹灯风格的赛博朋克暗色主题",
// ── CSS Tokens ────────────────────────────────────────────────────────────
tokens: {
light: {
// This theme is dark-only, so the light tokens just fall through to dark
"--background": "#0a0a1a",
"--foreground": "#e0e0ff",
"--border": "#2a2a4a",
"--input": "#1a1a3a",
"--ring": "#ff00ff",
"--content1": "#12122a",
"--divider": "#2a2a4a",
"--primary": "#ff00ff",
"--primary-foreground": "#ffffff",
"--secondary": "#00ffff",
"--secondary-foreground": "#000000",
"--danger": "#ff3366",
"--success": "#00ff88",
"--warning": "#ffaa00",
},
dark: {
"--background": "#0a0a1a",
"--foreground": "#e0e0ff",
"--border": "#2a2a4a",
"--input": "#1a1a3a",
"--ring": "#ff00ff",
"--content1": "#12122a",
"--divider": "#2a2a4a",
"--default-50": "#0d0d20",
"--default-100": "#14142e",
"--default-200": "#1e1e3c",
"--default-300": "#2a2a4a",
"--default-400": "#4a4a6a",
"--default-500": "#7a7a9a",
"--default-600": "#9a9aba",
"--default-700": "#babada",
"--default-800": "#dadaf0",
"--default-900": "#f0f0ff",
"--primary": "#ff00ff",
"--primary-foreground": "#ffffff",
"--primary-50": "#1a001a",
"--primary-100": "#330033",
"--primary-200": "#660066",
"--primary-300": "#990099",
"--primary-400": "#cc00cc",
"--primary-500": "#ff00ff",
"--primary-600": "#ff33ff",
"--primary-700": "#ff66ff",
"--primary-800": "#ff99ff",
"--primary-900": "#ffccff",
"--secondary": "#00ffff",
"--secondary-foreground": "#000000",
"--danger": "#ff3366",
"--success": "#00ff88",
"--warning": "#ffaa00",
},
},
// ── Raw CSS (glow effects, animations, fonts) ─────────────────────────────
css: `
/* Cyberpunk neon glow on primary buttons */
[data-flvx-theme="cyberpunk"] .bg-primary,
.bg-primary {
box-shadow: 0 0 12px rgba(255, 0, 255, 0.4),
0 0 24px rgba(255, 0, 255, 0.15);
}
/* Neon border glow on cards */
[data-flvx-theme="cyberpunk"] [class*="border"] {
border-color: rgba(255, 0, 255, 0.15);
}
/* Scanline overlay animation */
@keyframes flvx-scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0a0a1a; }
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #ff00ff, #00ffff);
border-radius: 3px;
}
`,
// ── Component Overrides ───────────────────────────────────────────────────
components: {
Button: CyberpunkButton,
},
// ── Lifecycle ─────────────────────────────────────────────────────────────
onActivate: () => {
// Force dark mode for this theme
document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = "dark";
// Mark the body for theme-specific CSS selectors
document.body.setAttribute("data-flvx-theme", "cyberpunk");
},
onDeactivate: () => {
document.body.removeAttribute("data-flvx-theme");
},
};
export default cyberpunkTheme;
+45
View File
@@ -0,0 +1,45 @@
/**
* @module @/themes
* Public API for the FLVX theme system.
*
* Usage:
* import { ThemeProvider, useThemeContext, registerTheme } from "@/themes";
*/
export type {
ThemePackage,
ThemeTokens,
ComponentKey,
LayoutKey,
PageKey,
} from "./types";
export {
registerTheme,
unregisterTheme,
getRegisteredThemes,
getTheme,
getActiveThemeId,
getActiveTheme,
activateTheme,
deactivateTheme,
reapplyActiveTheme,
initThemeSystem,
resolveComponent,
resolveLayout,
resolvePage,
getSavedMode,
saveMode,
getEffectiveMode,
subscribe,
} from "./registry";
export type { ThemeMode } from "./registry";
export {
ThemeProvider,
useThemeContext,
useThemedComponent,
useThemedLayout,
useThemedPage,
} from "./context";
+36
View File
@@ -0,0 +1,36 @@
/**
* Theme Loader — auto-registers all built-in themes
* ==================================================
* Import this module once at app startup (in provider.tsx or App.tsx).
*
* To add a new theme:
* 1. Create a folder under `src/themes/` (e.g. `src/themes/my-theme/`)
* 2. Export a `ThemePackage` as the default export from `index.ts`
* 3. Import and register it below
*/
import { registerTheme } from "./registry";
// ── Built-in themes ──────────────────────────────────────────────────────────
import defaultTheme from "./default";
import cyberpunkTheme from "./example-cyberpunk";
// Register all themes
registerTheme(defaultTheme);
registerTheme(cyberpunkTheme);
/*
* ── ADDING YOUR OWN THEME ──────────────────────────────────────────────────
*
* 1. Create your theme folder:
* src/themes/my-awesome-theme/
* ├── index.ts ← exports ThemePackage
* ├── components/ ← optional component overrides
* └── ...
*
* 2. Import and register here:
* import myTheme from "./my-awesome-theme";
* registerTheme(myTheme);
*
* That's it! The theme will appear in the theme picker.
*/
+263
View File
@@ -0,0 +1,263 @@
/**
* Theme Registry
* ==============
* Manages the set of installed themes and the currently active theme.
* Handles CSS variable injection, `<style>` element management, and
* lifecycle callbacks.
*
* This module is framework-agnostic (no React dependency). The React
* integration lives in `./context.tsx`.
*/
import type { ThemePackage, ThemeTokens, ComponentKey, LayoutKey, PageKey } from "./types";
// ─── internal state ──────────────────────────────────────────────────────────
const installed = new Map<string, ThemePackage>();
let activeId: string | null = null;
let injectedStyleEl: HTMLStyleElement | null = null;
const STORAGE_KEY = "flvx:active-theme";
const MODE_KEY = "flvx:theme"; // backwards-compat with old use-theme
type ChangeListener = () => void;
const changeListeners = new Set<ChangeListener>();
function notify() {
changeListeners.forEach((fn) => fn());
}
// ─── public API ──────────────────────────────────────────────────────────────
/**
* Register a theme package. Call this for every theme you want available.
* Registering a theme with an existing id replaces the previous one.
*/
export function registerTheme(pkg: ThemePackage): void {
installed.set(pkg.id, pkg);
notify();
}
/** Unregister a theme by id. */
export function unregisterTheme(id: string): void {
if (activeId === id) deactivateTheme();
installed.delete(id);
notify();
}
/** Get all registered themes. */
export function getRegisteredThemes(): ThemePackage[] {
return Array.from(installed.values());
}
/** Get a specific theme by id. */
export function getTheme(id: string): ThemePackage | undefined {
return installed.get(id);
}
/** Get the id of the currently active theme (or null). */
export function getActiveThemeId(): string | null {
return activeId;
}
/** Get the currently active ThemePackage (or null). */
export function getActiveTheme(): ThemePackage | null {
return activeId ? installed.get(activeId) ?? null : null;
}
// ─── theme mode ──────────────────────────────────────────────────────────────
export type ThemeMode = "light" | "dark" | "system";
export function resolveSystemMode(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function getSavedMode(): ThemeMode {
if (typeof window === "undefined") return "system";
const raw = localStorage.getItem(MODE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
return "system";
}
export function saveMode(mode: ThemeMode): void {
localStorage.setItem(MODE_KEY, mode);
notify();
}
export function getEffectiveMode(): "light" | "dark" {
const mode = getSavedMode();
return mode === "system" ? resolveSystemMode() : mode;
}
// ─── activation ──────────────────────────────────────────────────────────────
/**
* Activate a theme by id. This:
* 1. Calls `onDeactivate` on the previous theme.
* 2. Injects CSS tokens onto `document.documentElement`.
* 3. Injects the theme's `css` string into a `<style>` element.
* 4. Calls `onActivate` on the new theme.
* 5. Persists the choice to localStorage.
*/
export function activateTheme(id: string): void {
const pkg = installed.get(id);
if (!pkg) {
console.warn(`[FLVX themes] Theme "${id}" is not registered.`);
return;
}
// Deactivate previous
deactivateTheme();
activeId = id;
// Inject tokens
const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens);
// Inject custom CSS
if (pkg.css) {
injectedStyleEl = document.createElement("style");
injectedStyleEl.setAttribute("data-flvx-theme", id);
injectedStyleEl.textContent = pkg.css;
document.head.appendChild(injectedStyleEl);
}
// Update dark class
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
// Lifecycle
pkg.onActivate?.();
// Persist
localStorage.setItem(STORAGE_KEY, id);
notify();
}
/** Deactivate the current theme, reverting all overrides. */
export function deactivateTheme(): void {
const prev = activeId ? installed.get(activeId) : null;
prev?.onDeactivate?.();
// Remove injected tokens
clearInjectedTokens();
// Remove injected style element
if (injectedStyleEl) {
injectedStyleEl.remove();
injectedStyleEl = null;
}
activeId = null;
}
/** Re-apply the active theme (e.g. after mode changes). */
export function reapplyActiveTheme(): void {
if (activeId) {
const id = activeId;
// quick re-inject without full lifecycle
const pkg = installed.get(id);
if (!pkg) return;
clearInjectedTokens();
const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens);
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
} else {
// No theme active — just set dark class based on mode
const mode = getEffectiveMode();
const root = document.documentElement;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
}
notify();
}
// ─── component resolution ────────────────────────────────────────────────────
/**
* Resolve a component: returns the themed override if present, otherwise
* returns the fallback (default implementation).
*/
export function resolveComponent<P = any>(
key: ComponentKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.components?.[key];
return (override as React.ComponentType<P>) ?? fallback;
}
/** Resolve a layout override. */
export function resolveLayout(
key: LayoutKey,
fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> {
const pkg = activeId ? installed.get(activeId) : null;
return pkg?.layouts?.[key] ?? fallback;
}
/** Resolve a page override. */
export function resolvePage<P = any>(
key: PageKey,
fallback: React.ComponentType<P>,
): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.pages?.[key];
return (override as React.ComponentType<P>) ?? fallback;
}
// ─── subscriber API (for React) ──────────────────────────────────────────────
export function subscribe(listener: ChangeListener): () => void {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
}
// ─── initialisation ──────────────────────────────────────────────────────────
/**
* Call once at app boot. Restores the previously active theme from
* localStorage (if the theme is registered).
*/
export function initThemeSystem(): void {
const savedId = localStorage.getItem(STORAGE_KEY);
if (savedId && installed.has(savedId)) {
activateTheme(savedId);
} else {
// Just apply mode
reapplyActiveTheme();
}
}
// ─── internal helpers ────────────────────────────────────────────────────────
const injectedVars: string[] = [];
function injectTokens(tokens: ThemeTokens): void {
const root = document.documentElement;
for (const [varName, value] of Object.entries(tokens)) {
if (value !== undefined) {
root.style.setProperty(varName, value);
injectedVars.push(varName);
}
}
}
function clearInjectedTokens(): void {
const root = document.documentElement;
for (const varName of injectedVars) {
root.style.removeProperty(varName);
}
injectedVars.length = 0;
}
+299
View File
@@ -0,0 +1,299 @@
/**
* FLVX Theme System — Type Definitions
* =====================================
* This file defines the contract that every theme package must implement.
* Theme authors: read README.md first, then implement ThemePackage.
*/
import type React from "react";
import type { ComponentType } from "react";
// ─── CSS Variable Tokens ─────────────────────────────────────────────────────
/**
* Complete set of CSS variable tokens a theme can define.
* All values are valid CSS colour strings (hex, rgb, hsl, etc.).
* A theme does NOT need to provide every token — missing ones fall back
* to the default theme.
*/
export interface ThemeTokens {
/* ── base surfaces ─────────────────────── */
"--background"?: string;
"--foreground"?: string;
"--border"?: string;
"--input"?: string;
"--ring"?: string;
"--content1"?: string;
"--divider"?: string;
/* ── default (neutral) palette ─────────── */
"--default-50"?: string;
"--default-100"?: string;
"--default-200"?: string;
"--default-300"?: string;
"--default-400"?: string;
"--default-500"?: string;
"--default-600"?: string;
"--default-700"?: string;
"--default-800"?: string;
"--default-900"?: string;
/* ── primary ───────────────────────────── */
"--primary"?: string;
"--primary-foreground"?: string;
"--primary-50"?: string;
"--primary-100"?: string;
"--primary-200"?: string;
"--primary-300"?: string;
"--primary-400"?: string;
"--primary-500"?: string;
"--primary-600"?: string;
"--primary-700"?: string;
"--primary-800"?: string;
"--primary-900"?: string;
/* ── secondary ─────────────────────────── */
"--secondary"?: string;
"--secondary-foreground"?: string;
"--secondary-50"?: string;
"--secondary-100"?: string;
"--secondary-200"?: string;
"--secondary-300"?: string;
"--secondary-400"?: string;
"--secondary-500"?: string;
"--secondary-600"?: string;
"--secondary-700"?: string;
"--secondary-800"?: string;
"--secondary-900"?: string;
/* ── danger ────────────────────────────── */
"--danger"?: string;
"--danger-50"?: string;
"--danger-100"?: string;
"--danger-200"?: string;
"--danger-300"?: string;
"--danger-400"?: string;
"--danger-500"?: string;
"--danger-600"?: string;
"--danger-700"?: string;
"--danger-800"?: string;
"--danger-900"?: string;
/* ── success ───────────────────────────── */
"--success"?: string;
"--success-50"?: string;
"--success-100"?: string;
"--success-200"?: string;
"--success-300"?: string;
"--success-400"?: string;
"--success-500"?: string;
"--success-600"?: string;
"--success-700"?: string;
"--success-800"?: string;
"--success-900"?: string;
/* ── warning ───────────────────────────── */
"--warning"?: string;
"--warning-50"?: string;
"--warning-100"?: string;
"--warning-200"?: string;
"--warning-300"?: string;
"--warning-400"?: string;
"--warning-500"?: string;
"--warning-600"?: string;
"--warning-700"?: string;
"--warning-800"?: string;
"--warning-900"?: string;
/* ── typography ─────────────────────────── */
"--font-sans"?: string;
"--font-mono"?: string;
/* ── geometry ───────────────────────────── */
"--radius"?: string;
"--radius-sm"?: string;
"--radius-lg"?: string;
/** Escape hatch: any extra CSS variable */
[key: `--${string}`]: string | undefined;
}
// ─── Component Keys ──────────────────────────────────────────────────────────
/**
* All overridable component keys. These exactly correspond to the exports
* from `src/shadcn-bridge/heroui/*` and `src/components/*`.
*
* A theme only needs to override the components it wants to change.
* Every other component falls through to the default implementation.
*/
export type ComponentKey =
// shadcn-bridge/heroui primitives
| "Button"
| "Card"
| "CardHeader"
| "CardBody"
| "CardFooter"
| "Input"
| "Select"
| "SelectItem"
| "Switch"
| "Checkbox"
| "Chip"
| "Modal"
| "ModalContent"
| "ModalHeader"
| "ModalBody"
| "ModalFooter"
| "Table"
| "TableHeader"
| "TableBody"
| "TableRow"
| "TableCell"
| "TableColumn"
| "Tabs"
| "Tab"
| "Progress"
| "Spinner"
| "Divider"
| "Link"
| "Dropdown"
| "DropdownTrigger"
| "DropdownMenu"
| "DropdownItem"
| "Navbar"
| "NavbarContent"
| "NavbarItem"
| "Radio"
| "RadioGroup"
| "Accordion"
| "AccordionItem"
| "DatePicker"
| "Alert"
// app-level components
| "SearchBar"
| "BrandLogo"
| "VersionFooter"
| "PageWrapper"
| "PageState"
| "BatchActionResultModal";
/**
* All overridable layout keys.
* Layouts receive `{ children: React.ReactNode }` as props.
*/
export type LayoutKey = "AdminLayout" | "H5Layout" | "H5SimpleLayout" | "DefaultLayout";
/**
* All overridable page keys.
* Pages are rendered as route components — they receive no props from the
* router (params come from React Router hooks).
*/
export type PageKey =
| "LoginPage"
| "DashboardPage"
| "MonitorPage"
| "ForwardPage"
| "TunnelPage"
| "NodePage"
| "UserPage"
| "GroupPage"
| "ProfilePage"
| "LimitPage"
| "ConfigPage"
| "PanelSharingPage"
| "SettingsPage"
| "ChangePasswordPage";
// ─── Theme Package ───────────────────────────────────────────────────────────
/**
* The main interface a theme must export as its default export.
*
* Minimal theme (colours only):
* ```ts
* const theme: ThemePackage = {
* id: "my-theme",
* name: "My Theme",
* author: "Me",
* version: "1.0.0",
* tokens: { light: { "--primary": "#ff6600" } },
* };
* export default theme;
* ```
*
* Full theme (components + layouts + pages):
* ```ts
* import MyButton from "./components/button";
* import MyAdminLayout from "./layouts/admin";
* const theme: ThemePackage = {
* id: "my-theme",
* ...
* tokens: { ... },
* components: { Button: MyButton },
* layouts: { AdminLayout: MyAdminLayout },
* pages: { DashboardPage: MyDashboard },
* css: `body { font-family: "Comic Sans MS" !important; }`,
* onActivate: () => console.log("Activated!"),
* };
* ```
*/
export interface ThemePackage {
/** Unique identifier (kebab-case, e.g. "midnight-purple"). */
id: string;
/** Human-readable display name. */
name: string;
/** Author name or GitHub handle. */
author: string;
/** SemVer version string. */
version: string;
/** Short description shown in theme picker. */
description?: string;
/** Absolute or relative URL to a preview screenshot. */
preview?: string;
// ── Styling ────────────────────────────────────────────────────────────────
/**
* CSS variable token overrides. Provide `light`, `dark`, or both.
* Only the variables you specify will be overridden; all others keep
* the default values from `globals.css`.
*/
tokens?: {
light?: ThemeTokens;
dark?: ThemeTokens;
};
/**
* Raw CSS string injected into a `<style>` element when this theme is
* active. Use this for custom selectors, animations, font-faces, etc.
* The style element is removed when the theme is deactivated.
*/
css?: string;
// ── Component / Layout / Page Overrides ────────────────────────────────────
/**
* Map of component overrides. The replacement component MUST accept the
* same props interface as the original. Import types from
* `@/shadcn-bridge/heroui/*` for reference.
*/
components?: Partial<Record<ComponentKey, ComponentType<any>>>;
/**
* Map of layout overrides. Each layout receives `{ children }`.
*/
layouts?: Partial<Record<LayoutKey, ComponentType<{ children: React.ReactNode }>>>;
/**
* Map of page overrides. Each page is a full route-level component.
*/
pages?: Partial<Record<PageKey, ComponentType<any>>>;
// ── Lifecycle ──────────────────────────────────────────────────────────────
/** Called when this theme becomes the active theme. */
onActivate?: () => void;
/** Called when this theme is being replaced by another. */
onDeactivate?: () => void;
}