mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a1fc2abf2 | |||
| 0a1ec60750 | |||
| 9ec35d2f2f | |||
| c914040b7d | |||
| 822362c44c | |||
| 80f5935b76 | |||
| 433c8aab13 | |||
| 949dfcd42d | |||
| 1580e4ee10 | |||
| 32e4f0f514 | |||
| ca3a643ef7 | |||
| ce2b234843 | |||
| ac3506847c | |||
| 6e3d604618 | |||
| 4417ece7cd | |||
| bab4371ba7 | |||
| 960c97cee4 | |||
| 9d05d75fd6 | |||
| c137bdcc63 | |||
| b7065f6e99 | |||
| bd4e1f66cb | |||
| 9f0670f4d0 | |||
| 32e338d295 | |||
| f6a753baa3 | |||
| 322a10bb9d | |||
| 27c13d6c47 | |||
| f45f96063a | |||
| 1780be73b9 | |||
| addf8e2089 | |||
| 75cd60ea3e | |||
| fe42a77409 | |||
| ce9abf457f | |||
| 3c57a5ac84 | |||
| ff7c91d277 | |||
| 1498f3052d | |||
| 6b1264ae90 | |||
| 18445ec063 | |||
| 08bc91e5c9 | |||
| 2f424bea31 | |||
| bc75ed745d | |||
| 78fb9a31d6 |
@@ -1,9 +1,9 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Mar 19 2026
|
||||
**Commit:** 6458b5a
|
||||
**Generated:** Sat Mar 21 2026
|
||||
**Commit:** 0767c9c
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-alpha5
|
||||
**Tag:** 2.1.9-beta13
|
||||
|
||||
## OVERVIEW
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -42,6 +42,8 @@ type Handler struct {
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
|
||||
qualityProber *tunnelQualityProber
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -93,6 +95,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
}
|
||||
h.healthCheck = health.NewChecker(repo, h.wsServer)
|
||||
h.qualityProber = newTunnelQualityProber(h)
|
||||
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
||||
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
|
||||
metricInfo := metrics.SystemInfo{
|
||||
@@ -229,6 +232,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels/quality", h.monitorTunnelQualityHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics)
|
||||
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
|
||||
@@ -1234,6 +1238,21 @@ func nullableNullInt64(v sql.NullInt64) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// flowCryptoCache caches AES crypto instances by secret to avoid per-request SHA256+GCM init.
|
||||
var flowCryptoCache sync.Map
|
||||
|
||||
func getOrCreateFlowCrypto(secret string) *security.AESCrypto {
|
||||
if v, ok := flowCryptoCache.Load(secret); ok {
|
||||
return v.(*security.AESCrypto)
|
||||
}
|
||||
c, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
flowCryptoCache.Store(secret, c)
|
||||
return c
|
||||
}
|
||||
|
||||
func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
|
||||
defer body.Close()
|
||||
raw, err := io.ReadAll(body)
|
||||
@@ -1254,8 +1273,8 @@ func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
crypto, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
crypto := getOrCreateFlowCrypto(secret)
|
||||
if crypto == nil {
|
||||
return text, nil
|
||||
}
|
||||
plain, err := crypto.Decrypt(wrap.Data)
|
||||
|
||||
@@ -18,7 +18,7 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(5)
|
||||
h.jobsWG.Add(6)
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
go h.runHourlyStatsLoop(ctx)
|
||||
@@ -26,6 +26,7 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
go h.runNodeRenewalCycleLoop(ctx)
|
||||
go h.runMetricsIngestion(ctx)
|
||||
go h.runHealthChecks(ctx)
|
||||
go h.runTunnelQualityProber(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) StopBackgroundJobs() {
|
||||
@@ -63,6 +64,13 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h.qualityProber != nil {
|
||||
h.qualityProber.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
|
||||
@@ -207,6 +207,98 @@ func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request
|
||||
response.WriteJSON(w, response.OK(metric))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try in-memory cache first
|
||||
if h.qualityProber != nil {
|
||||
items := h.qualityProber.GetAll()
|
||||
if len(items) > 0 {
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to database (latest per tunnel)
|
||||
qualities, err := h.repo.GetLatestTunnelQualities()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
|
||||
for _, q := range qualities {
|
||||
snapshots = append(snapshots, tunnelQualitySnapshot{
|
||||
TunnelID: q.TunnelID,
|
||||
EntryToExitLatency: q.EntryToExitLatency,
|
||||
ExitToBingLatency: q.ExitToBingLatency,
|
||||
EntryToExitLoss: q.EntryToExitLoss,
|
||||
ExitToBingLoss: q.ExitToBingLoss,
|
||||
Success: q.Success == 1,
|
||||
ErrorMessage: q.ErrorMessage,
|
||||
Timestamp: q.Timestamp,
|
||||
})
|
||||
}
|
||||
response.WriteJSON(w, response.OK(snapshots))
|
||||
}
|
||||
|
||||
// monitorTunnelQualityHistory returns quality probe history for charting.
|
||||
// GET /api/v1/monitor/tunnels/{id}/quality?start=...&end=...
|
||||
// Mirrors monitorTunnelMetrics / monitorServiceResultsHandler pattern.
|
||||
func (h *Handler) monitorTunnelQualityHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/quality")
|
||||
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||
if err != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
startMs := now - defaultMetricsRangeMs
|
||||
endMs := now
|
||||
|
||||
if s := r.URL.Query().Get("start"); s != "" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
startMs = v
|
||||
}
|
||||
}
|
||||
if e := r.URL.Query().Get("end"); e != "" {
|
||||
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
|
||||
endMs = v
|
||||
}
|
||||
}
|
||||
if 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.GetTunnelQualityHistory(tunnelID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -216,7 +308,23 @@ func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/metrics")
|
||||
path := r.URL.Path
|
||||
prefix := "/api/v1/monitor/tunnels/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的路径"))
|
||||
return
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
|
||||
// Route: /api/v1/monitor/tunnels/{id}/quality
|
||||
if strings.HasSuffix(rest, "/quality") {
|
||||
h.monitorTunnelQualityHistory(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Route: /api/v1/monitor/tunnels/{id}/metrics (original)
|
||||
tunnelIDStr := extractPathParam(path, prefix, "/metrics")
|
||||
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||
if err != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||
@@ -587,6 +695,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 {
|
||||
@@ -612,6 +744,16 @@ func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
// Try in-memory cache first (updated every 1s)
|
||||
if h.healthCheck != nil {
|
||||
cached := h.healthCheck.GetLatestCached()
|
||||
if len(cached) > 0 {
|
||||
response.WriteJSON(w, response.OK(cached))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to database
|
||||
results, err := h.repo.GetLatestServiceMonitorResults()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
|
||||
@@ -35,6 +35,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
}
|
||||
|
||||
forwardDeltas := make(map[int64]tunnelTrafficDelta)
|
||||
var skippedParse, skippedZero int
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.N)
|
||||
if name == "" || name == "web_api" {
|
||||
@@ -42,9 +43,11 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
}
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(name)
|
||||
if !ok {
|
||||
skippedParse++
|
||||
continue
|
||||
}
|
||||
if item.D == 0 && item.U == 0 {
|
||||
skippedZero++
|
||||
continue
|
||||
}
|
||||
d := forwardDeltas[forwardID]
|
||||
@@ -53,6 +56,9 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
forwardDeltas[forwardID] = d
|
||||
}
|
||||
if len(forwardDeltas) == 0 {
|
||||
if len(items) > 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_forward_deltas node_id=%d items=%d skipped_parse=%d skipped_zero=%d", nodeID, len(items), skippedParse, skippedZero)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,6 +73,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
return
|
||||
}
|
||||
if len(forwardTunnelMap) == 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_tunnel_map node_id=%d forward_ids=%v", nodeID, forwardIDs)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,5 +114,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
|
||||
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
|
||||
log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err)
|
||||
} else {
|
||||
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const (
|
||||
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.
|
||||
type tunnelQualitySnapshot struct {
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
EntryToExitLatency float64 `json:"entryToExitLatency"`
|
||||
ExitToBingLatency float64 `json:"exitToBingLatency"`
|
||||
EntryToExitLoss float64 `json:"entryToExitLoss"`
|
||||
ExitToBingLoss float64 `json:"exitToBingLoss"`
|
||||
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.
|
||||
// Design mirrors health.Checker: background goroutine with worker pool + scheduled cleanup.
|
||||
type tunnelQualityProber struct {
|
||||
handler *Handler
|
||||
cache sync.Map // tunnelID (int64) → *tunnelQualitySnapshot
|
||||
ctx context.Context
|
||||
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).
|
||||
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &tunnelQualityProber{
|
||||
handler: h,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
interval: tunnelQualityProbeInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the background probe loop (call from jobs.go).
|
||||
func (p *tunnelQualityProber) Start(ctx context.Context) {
|
||||
// Use the provided context so we stop with other background jobs.
|
||||
p.ctx, p.cancel = context.WithCancel(ctx)
|
||||
p.loop()
|
||||
}
|
||||
|
||||
// Stop halts the background probe loop.
|
||||
func (p *tunnelQualityProber) Stop() {
|
||||
p.cancel()
|
||||
}
|
||||
|
||||
// GetAll returns all cached quality snapshots (latest per tunnel).
|
||||
func (p *tunnelQualityProber) GetAll() []tunnelQualitySnapshot {
|
||||
var items []tunnelQualitySnapshot
|
||||
p.cache.Range(func(_, value interface{}) bool {
|
||||
if snap, ok := value.(*tunnelQualitySnapshot); ok {
|
||||
items = append(items, *snap)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) loop() {
|
||||
// Initial delay to let the system boot up
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
// Run once immediately
|
||||
p.probeAll()
|
||||
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.probeAll()
|
||||
p.maybePrune()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
|
||||
func (p *tunnelQualityProber) maybePrune() {
|
||||
now := time.Now().UnixMilli()
|
||||
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
|
||||
return
|
||||
}
|
||||
p.lastPrune = now
|
||||
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := now - int64(tunnelQualityRetention/time.Millisecond)
|
||||
if err := h.repo.PruneTunnelQualityResults(cutoff); err != nil {
|
||||
log.Printf("tunnel_quality_prober: prune err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
tunnelIDs, err := h.repo.ListEnabledTunnelIDs()
|
||||
if err != nil {
|
||||
log.Printf("tunnel_quality_prober: list enabled tunnels err=%v", err)
|
||||
return
|
||||
}
|
||||
if len(tunnelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Probe tunnels concurrently with a worker limit
|
||||
// (mirrors health.Checker worker pool pattern)
|
||||
const maxWorkers = 20
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, tunnelID := range tunnelIDs {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(tid int64) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
p.probeTunnel(tid)
|
||||
}(tunnelID)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
h := p.handler
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snap := &tunnelQualitySnapshot{
|
||||
TunnelID: tunnelID,
|
||||
Timestamp: now,
|
||||
}
|
||||
|
||||
// Get tunnel chain info
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
snap.ErrorMessage = "隧道不存在"
|
||||
p.storeResult(snap)
|
||||
return
|
||||
}
|
||||
|
||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
||||
if err != nil || len(chainRows) == 0 {
|
||||
snap.ErrorMessage = "隧道配置不完整"
|
||||
p.storeResult(snap)
|
||||
return
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||
inNodes, _, outNodes := splitChainNodeGroups(chainRows)
|
||||
|
||||
options := diagnosisExecOptions{
|
||||
commandTimeout: tunnelQualityProbeTimeout,
|
||||
pingTimeoutMS: tunnelQualityPingTimeoutMs,
|
||||
timeoutMessage: "探测超时",
|
||||
}
|
||||
|
||||
switch tunnel.Type {
|
||||
case 1:
|
||||
// Port forwarding: entry → Bing only
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
snap.Success = true
|
||||
} else {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
// Tunnel forwarding: entry → exit + exit → Bing
|
||||
probeOK := true
|
||||
|
||||
if len(inNodes) > 0 && len(outNodes) > 0 {
|
||||
// Entry → Exit
|
||||
targetNode, nodeErr := h.getNodeRecord(outNodes[0].NodeID)
|
||||
if nodeErr == nil && targetNode != nil {
|
||||
fromNode, _ := h.getNodeRecord(inNodes[0].NodeID)
|
||||
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, outNodes[0].Port, ipPreference, outNodes[0].ConnectIP)
|
||||
if resolveErr == nil {
|
||||
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, targetIP, targetPort, options)
|
||||
if err == nil {
|
||||
snap.EntryToExitLatency = lat
|
||||
snap.EntryToExitLoss = loss
|
||||
} else {
|
||||
snap.EntryToExitLatency = -1
|
||||
snap.EntryToExitLoss = 100
|
||||
probeOK = false
|
||||
}
|
||||
} else {
|
||||
snap.ErrorMessage = resolveErr.Error()
|
||||
probeOK = false
|
||||
}
|
||||
} else {
|
||||
snap.ErrorMessage = "出口节点不可用"
|
||||
probeOK = false
|
||||
}
|
||||
}
|
||||
|
||||
// Exit → Bing
|
||||
if len(outNodes) > 0 {
|
||||
lat, loss, err := p.tcpPingNode(outNodes[0].NodeID, "www.bing.com", 443, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
} else {
|
||||
if snap.ErrorMessage == "" {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
probeOK = false
|
||||
}
|
||||
}
|
||||
|
||||
snap.Success = probeOK
|
||||
default:
|
||||
// Unknown type: entry → Bing
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
snap.Success = true
|
||||
} else {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.storeResult(snap)
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) {
|
||||
h := p.handler
|
||||
if h == nil {
|
||||
return 0, 100, nil
|
||||
}
|
||||
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr != nil {
|
||||
return 0, 100, nodeErr
|
||||
}
|
||||
|
||||
var pingData map[string]interface{}
|
||||
var pingErr error
|
||||
if node != nil && node.IsRemote == 1 {
|
||||
pingData, pingErr = h.tcpPingViaRemoteNode(node, ip, port, options)
|
||||
} else {
|
||||
pingData, pingErr = h.tcpPingViaNode(nodeID, ip, port, options)
|
||||
}
|
||||
if pingErr != nil {
|
||||
return 0, 100, pingErr
|
||||
}
|
||||
|
||||
avgTime := asFloat(pingData["averageTime"], 0)
|
||||
packetLoss := asFloat(pingData["packetLoss"], 100)
|
||||
|
||||
return avgTime, packetLoss, nil
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
|
||||
if snap == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
|
||||
successInt := 0
|
||||
if snap.Success {
|
||||
successInt = 1
|
||||
}
|
||||
|
||||
q := &model.TunnelQuality{
|
||||
TunnelID: snap.TunnelID,
|
||||
EntryToExitLatency: snap.EntryToExitLatency,
|
||||
ExitToBingLatency: snap.ExitToBingLatency,
|
||||
EntryToExitLoss: snap.EntryToExitLoss,
|
||||
ExitToBingLoss: snap.ExitToBingLoss,
|
||||
Success: successInt,
|
||||
ErrorMessage: snap.ErrorMessage,
|
||||
Timestamp: snap.Timestamp,
|
||||
}
|
||||
if err := h.repo.InsertTunnelQuality(q); err != nil {
|
||||
log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
@@ -714,3 +714,20 @@ type ServiceMonitorResult struct {
|
||||
}
|
||||
|
||||
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" }
|
||||
|
||||
// TunnelQuality stores periodic probe results for a tunnel.
|
||||
// Unlike the old upsert model, rows accumulate for history/charting.
|
||||
// Old rows are pruned periodically (default: keep 24h).
|
||||
type TunnelQuality struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_quality_tunnel_time,priority:1" json:"tunnelId"`
|
||||
EntryToExitLatency float64 `gorm:"column:entry_to_exit_latency" json:"entryToExitLatency"`
|
||||
ExitToBingLatency float64 `gorm:"column:exit_to_bing_latency" json:"exitToBingLatency"`
|
||||
EntryToExitLoss float64 `gorm:"column:entry_to_exit_loss" json:"entryToExitLoss"`
|
||||
ExitToBingLoss float64 `gorm:"column:exit_to_bing_loss" json:"exitToBingLoss"`
|
||||
Success int `gorm:"not null;default:1" json:"success"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"`
|
||||
Timestamp int64 `gorm:"not null;index:idx_tunnel_quality_tunnel_time,priority:2;index:idx_tunnel_quality_time" json:"timestamp"`
|
||||
}
|
||||
|
||||
func (TunnelQuality) TableName() string { return "tunnel_quality" }
|
||||
|
||||
@@ -51,6 +51,7 @@ type NodeMetric = model.NodeMetric
|
||||
type TunnelMetric = model.TunnelMetric
|
||||
type ServiceMonitor = model.ServiceMonitor
|
||||
type ServiceMonitorResult = model.ServiceMonitorResult
|
||||
type TunnelQuality = model.TunnelQuality
|
||||
|
||||
// ─── Repository ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -191,6 +192,7 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
&model.TunnelMetric{},
|
||||
&model.ServiceMonitor{},
|
||||
&model.ServiceMonitorResult{},
|
||||
&model.TunnelQuality{},
|
||||
}
|
||||
|
||||
if db.Dialector.Name() != "sqlite" {
|
||||
@@ -675,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
|
||||
@@ -3419,10 +3422,10 @@ func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) er
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"bytes_in": gorm.Expr("bytes_in + excluded.bytes_in"),
|
||||
"bytes_out": gorm.Expr("bytes_out + excluded.bytes_out"),
|
||||
"connections": gorm.Expr("connections + excluded.connections"),
|
||||
"errors": gorm.Expr("errors + excluded.errors"),
|
||||
"bytes_in": gorm.Expr("tunnel_metric.bytes_in + excluded.bytes_in"),
|
||||
"bytes_out": gorm.Expr("tunnel_metric.bytes_out + excluded.bytes_out"),
|
||||
"connections": gorm.Expr("tunnel_metric.connections + excluded.connections"),
|
||||
"errors": gorm.Expr("tunnel_metric.errors + excluded.errors"),
|
||||
// avg_latency_ms is not additive; keep the existing bucket value.
|
||||
}),
|
||||
}).CreateInBatches(rows, 100).Error
|
||||
@@ -3566,6 +3569,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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
|
||||
}
|
||||
var nodes []model.Node
|
||||
err := r.db.Select("id", "inx", "name", "status", "updated_time").
|
||||
Where("is_remote = ?", 0).
|
||||
Order("inx ASC, id ASC").
|
||||
Find(&nodes).Error
|
||||
return nodes, err
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
// InsertTunnelQuality appends a tunnel quality probe result.
|
||||
// (Follows the same pattern as InsertServiceMonitorResult.)
|
||||
func (r *Repository) InsertTunnelQuality(q *model.TunnelQuality) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if q == nil || q.TunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(q).Error
|
||||
}
|
||||
|
||||
// GetTunnelQualityHistory returns quality probe results for a tunnel
|
||||
// within a time range, ordered by timestamp ascending.
|
||||
// (Mirrors GetServiceMonitorResults pattern.)
|
||||
func (r *Repository) GetTunnelQualityHistory(tunnelID int64, startMs, endMs int64) ([]model.TunnelQuality, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var results []model.TunnelQuality
|
||||
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
|
||||
Order("timestamp ASC").
|
||||
Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetLatestTunnelQualities returns the newest quality result per tunnel_id.
|
||||
// (Mirrors GetLatestServiceMonitorResults pattern.)
|
||||
func (r *Repository) GetLatestTunnelQualities() ([]model.TunnelQuality, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []model.TunnelQuality
|
||||
|
||||
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
|
||||
q := `
|
||||
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
|
||||
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp
|
||||
FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
|
||||
FROM tunnel_quality
|
||||
) t
|
||||
WHERE rn = 1
|
||||
ORDER BY tunnel_id ASC
|
||||
`
|
||||
if err := r.db.Raw(q).Scan(&results).Error; err == nil {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Fallback for older SQLite
|
||||
results = nil
|
||||
err := r.db.Order("timestamp DESC, id DESC").Limit(5000).Find(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(results))
|
||||
out := make([]model.TunnelQuality, 0, len(results))
|
||||
for _, row := range results {
|
||||
if row.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[row.TunnelID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[row.TunnelID] = struct{}{}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PruneTunnelQualityResults deletes quality results older than the given timestamp.
|
||||
// (Mirrors PruneServiceMonitorResults pattern.)
|
||||
func (r *Repository) PruneTunnelQualityResults(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelQuality{}).Error
|
||||
}
|
||||
|
||||
// ListEnabledTunnelIDs returns IDs of all tunnels with status=1.
|
||||
func (r *Repository) ListEnabledTunnelIDs() ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.Tunnel{}).Where("status = ?", 1).Pluck("id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type nodeSession struct {
|
||||
nodeID int64
|
||||
secret string
|
||||
conn *connWrap
|
||||
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
|
||||
}
|
||||
|
||||
type commandResponse struct {
|
||||
@@ -211,7 +212,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
_ = old.conn.conn.Close()
|
||||
delete(s.byConn, old.conn.conn)
|
||||
}
|
||||
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw}
|
||||
// 初始化 AES 加密器并缓存(仅创建一次)
|
||||
var nodeCrypto *security.AESCrypto
|
||||
if strings.TrimSpace(secret) != "" {
|
||||
nodeCrypto, _ = security.NewAESCrypto(secret)
|
||||
}
|
||||
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw, crypto: nodeCrypto}
|
||||
s.nodes[nodeID] = ns
|
||||
s.byConn[conn] = ns
|
||||
s.mu.Unlock()
|
||||
@@ -251,7 +257,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
return
|
||||
}
|
||||
|
||||
msg := decryptIfNeeded(payload, secret)
|
||||
msg := decryptIfNeeded(payload, ns.crypto, secret)
|
||||
s.tryResolvePending(nodeID, msg)
|
||||
|
||||
var parsed struct {
|
||||
@@ -259,6 +265,26 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
}
|
||||
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type != "" {
|
||||
switch parsed.Type {
|
||||
case "metric":
|
||||
// Agent 新版指标消息:{type:"metric", data:{...}}
|
||||
var envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(msg), &envelope); err == nil && len(envelope.Data) > 0 {
|
||||
// 解析 SystemInfo 并调用 hook
|
||||
var sysInfo SystemInfo
|
||||
if json.Unmarshal(envelope.Data, &sysInfo) == nil {
|
||||
s.mu.RLock()
|
||||
onMetric := s.onNodeMetric
|
||||
s.mu.RUnlock()
|
||||
if onMetric != nil {
|
||||
go onMetric(nodeID, sysInfo)
|
||||
}
|
||||
}
|
||||
// 广播内层 data 给前端(保持平坦结构兼容性)
|
||||
s.broadcastTyped(nodeID, "metric", string(envelope.Data))
|
||||
}
|
||||
continue
|
||||
case "UpgradeProgress":
|
||||
s.broadcastTyped(nodeID, "upgrade_progress", msg)
|
||||
continue
|
||||
@@ -270,6 +296,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧版 Agent:无 type 字段的系统信息消息
|
||||
if looksLikeSystemInfoMessage(msg) {
|
||||
var sysInfo SystemInfo
|
||||
if err := json.Unmarshal([]byte(msg), &sysInfo); err == nil {
|
||||
@@ -372,13 +399,8 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
|
||||
}
|
||||
|
||||
messageData := rawCmd
|
||||
if strings.TrimSpace(ns.secret) != "" {
|
||||
crypto, err := security.NewAESCrypto(ns.secret)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(rawCmd)
|
||||
if ns.crypto != nil {
|
||||
encrypted, err := ns.crypto.Encrypt(rawCmd)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
@@ -428,6 +450,11 @@ func (s *Server) tryResolvePending(nodeID int64, message string) {
|
||||
return
|
||||
}
|
||||
|
||||
// 快速短路:指标消息永远不含 requestId,跳过完整 JSON 解析
|
||||
if !strings.Contains(message, "\"requestId\"") {
|
||||
return
|
||||
}
|
||||
|
||||
var resp commandResponse
|
||||
if err := json.Unmarshal([]byte(message), &resp); err != nil {
|
||||
return
|
||||
@@ -545,18 +572,22 @@ func (s *Server) broadcastToAdmins(message string) {
|
||||
}
|
||||
}
|
||||
|
||||
func decryptIfNeeded(payload []byte, secret string) string {
|
||||
func decryptIfNeeded(payload []byte, crypto *security.AESCrypto, secret string) string {
|
||||
text := string(payload)
|
||||
var wrap encryptedMessage
|
||||
if err := json.Unmarshal(payload, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
|
||||
return text
|
||||
}
|
||||
|
||||
crypto, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
// 优先使用缓存的 crypto 实例
|
||||
c := crypto
|
||||
if c == nil && strings.TrimSpace(secret) != "" {
|
||||
c, _ = security.NewAESCrypto(secret)
|
||||
}
|
||||
if c == nil {
|
||||
return text
|
||||
}
|
||||
plain, err := crypto.Decrypt(wrap.Data)
|
||||
plain, err := c.Decrypt(wrap.Data)
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
|
||||
+4
-1
@@ -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/`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -146,6 +147,9 @@ type ServiceMonitorCheckResult struct {
|
||||
const (
|
||||
reporterReadWait = 60 * time.Second
|
||||
reporterWriteWait = 5 * time.Second
|
||||
wsPingInterval = 20 * time.Second // 独立 WebSocket ping 间隔
|
||||
initialBackoff = 2 * time.Second // 重连初始退避
|
||||
maxBackoff = 2 * time.Minute // 重连最大退避
|
||||
)
|
||||
|
||||
type WebSocketReporter struct {
|
||||
@@ -155,15 +159,15 @@ type WebSocketReporter struct {
|
||||
version string // 保存版本号
|
||||
preferredWSScheme string
|
||||
conn *websocket.Conn
|
||||
reconnectTime time.Duration
|
||||
curBackoff time.Duration // 当前重连退避间隔
|
||||
pingInterval time.Duration
|
||||
configInterval time.Duration
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
connected bool
|
||||
connecting bool // 新增:正在连接状态
|
||||
connMutex sync.Mutex // 新增:连接状态锁
|
||||
aesCrypto *crypto.AESCrypto // 新增:AES加密器
|
||||
connecting bool // 正在连接状态
|
||||
connMutex sync.Mutex // 连接状态锁
|
||||
aesCrypto *crypto.AESCrypto // AES加密器
|
||||
}
|
||||
|
||||
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
@@ -185,8 +189,8 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
|
||||
|
||||
return &WebSocketReporter{
|
||||
url: serverURL,
|
||||
reconnectTime: 5 * time.Second, // 重连间隔
|
||||
pingInterval: 5 * time.Second, // 指标上报间隔
|
||||
curBackoff: initialBackoff, // 当前退避间隔
|
||||
pingInterval: 1 * time.Second, // 指标上报间隔(每秒采集)
|
||||
configInterval: 10 * time.Minute, // 配置上报间隔
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
@@ -204,10 +208,17 @@ func (w *WebSocketReporter) Start() {
|
||||
// Stop 停止WebSocket报告器
|
||||
func (w *WebSocketReporter) Stop() {
|
||||
w.cancel()
|
||||
w.connMutex.Lock()
|
||||
if w.conn != nil {
|
||||
w.conn.Close()
|
||||
}
|
||||
w.connMutex.Unlock()
|
||||
}
|
||||
|
||||
// backoffWithJitter 返回带随机抖动的退避时间(±25%)
|
||||
func backoffWithJitter(base time.Duration) time.Duration {
|
||||
jitter := time.Duration(float64(base) * (0.75 + rand.Float64()*0.5))
|
||||
return jitter
|
||||
}
|
||||
|
||||
// run 主运行循环
|
||||
@@ -224,23 +235,32 @@ func (w *WebSocketReporter) run() {
|
||||
|
||||
if needConnect {
|
||||
if err := w.connect(); err != nil {
|
||||
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, w.reconnectTime)
|
||||
wait := backoffWithJitter(w.curBackoff)
|
||||
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, wait)
|
||||
// 指数退避:翻倍当前退避间隔,上限 maxBackoff
|
||||
w.curBackoff *= 2
|
||||
if w.curBackoff > maxBackoff {
|
||||
w.curBackoff = maxBackoff
|
||||
}
|
||||
select {
|
||||
case <-time.After(w.reconnectTime):
|
||||
case <-time.After(wait):
|
||||
continue
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
// 连接成功:重置退避
|
||||
w.curBackoff = initialBackoff
|
||||
}
|
||||
|
||||
// 连接成功,开始发送消息
|
||||
if w.connected {
|
||||
w.handleConnection()
|
||||
} else {
|
||||
wait := backoffWithJitter(w.curBackoff)
|
||||
// 如果连接失败,等待重试
|
||||
select {
|
||||
case <-time.After(w.reconnectTime):
|
||||
case <-time.After(wait):
|
||||
continue
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
@@ -473,15 +493,34 @@ func (w *WebSocketReporter) handleConnection() {
|
||||
// 启动消息接收goroutine
|
||||
go w.receiveMessages()
|
||||
|
||||
// 主发送循环
|
||||
ticker := time.NewTicker(w.pingInterval)
|
||||
defer ticker.Stop()
|
||||
// 指标上报 ticker
|
||||
metricTicker := time.NewTicker(w.pingInterval)
|
||||
defer metricTicker.Stop()
|
||||
|
||||
// 独立 WebSocket keepalive ping ticker
|
||||
pingTicker := time.NewTicker(wsPingInterval)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
|
||||
case <-pingTicker.C:
|
||||
// 发送 WebSocket ping 保活,独立于指标上报
|
||||
w.connMutex.Lock()
|
||||
conn := w.conn
|
||||
isConnected := w.connected
|
||||
w.connMutex.Unlock()
|
||||
if !isConnected || conn == nil {
|
||||
return
|
||||
}
|
||||
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(reporterWriteWait)); err != nil {
|
||||
fmt.Printf("❌ 发送WebSocket ping失败: %v,准备重连\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
case <-metricTicker.C:
|
||||
// 检查连接状态
|
||||
w.connMutex.Lock()
|
||||
isConnected := w.connected
|
||||
@@ -548,6 +587,37 @@ func (w *WebSocketReporter) collectSystemInfo() SystemInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// encryptPayload 加密 JSON 数据,返回加密后的消息字节(若加密失败则回退到原始数据)
|
||||
func (w *WebSocketReporter) encryptPayload(jsonData []byte) []byte {
|
||||
if w.aesCrypto == nil {
|
||||
return jsonData
|
||||
}
|
||||
|
||||
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
|
||||
return jsonData
|
||||
}
|
||||
|
||||
encryptedMessage := map[string]interface{}{
|
||||
"encrypted": true,
|
||||
"data": encryptedData,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
messageData, err := json.Marshal(encryptedMessage)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
|
||||
return jsonData
|
||||
}
|
||||
return messageData
|
||||
}
|
||||
|
||||
// metricEnvelope wraps SystemInfo with a type field for fast identification on the panel side.
|
||||
type metricEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
Data SystemInfo `json:"data"`
|
||||
}
|
||||
|
||||
// sendSystemInfo 发送系统信息
|
||||
func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
|
||||
w.connMutex.Lock()
|
||||
@@ -557,42 +627,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
|
||||
return fmt.Errorf("连接未建立")
|
||||
}
|
||||
|
||||
// 转换为JSON
|
||||
jsonData, err := json.Marshal(sysInfo)
|
||||
// 使用 type:"metric" 信封包装,Panel 可通过 type 字段直接识别指标消息
|
||||
envelope := metricEnvelope{Type: "metric", Data: sysInfo}
|
||||
jsonData, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化系统信息失败: %v", err)
|
||||
}
|
||||
|
||||
var messageData []byte
|
||||
messageData := w.encryptPayload(jsonData)
|
||||
|
||||
// 如果有加密器,则加密数据
|
||||
if w.aesCrypto != nil {
|
||||
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
|
||||
messageData = jsonData
|
||||
} else {
|
||||
// 创建加密消息包装器
|
||||
encryptedMessage := map[string]interface{}{
|
||||
"encrypted": true,
|
||||
"data": encryptedData,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
messageData, err = json.Marshal(encryptedMessage)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
|
||||
messageData = jsonData
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messageData = jsonData
|
||||
}
|
||||
|
||||
// 设置写入超时
|
||||
w.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
if err := w.conn.WriteMessage(websocket.TextMessage, messageData); err != nil {
|
||||
w.connected = false // 标记连接已断开
|
||||
w.connected = false
|
||||
return fmt.Errorf("写入消息失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -601,23 +648,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
|
||||
|
||||
// receiveMessages 接收服务端发送的消息
|
||||
func (w *WebSocketReporter) receiveMessages() {
|
||||
// 获取连接引用一次即可,连接生命周期由 handleConnection 管理
|
||||
w.connMutex.Lock()
|
||||
conn := w.conn
|
||||
w.connMutex.Unlock()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
default:
|
||||
w.connMutex.Lock()
|
||||
conn := w.conn
|
||||
connected := w.connected
|
||||
w.connMutex.Unlock()
|
||||
|
||||
if conn == nil || !connected {
|
||||
return
|
||||
}
|
||||
|
||||
// 设置读取超时
|
||||
conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
|
||||
messageType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
@@ -705,12 +748,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
}
|
||||
|
||||
if cmdMsg.Type != "call" {
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
}
|
||||
// 所有命令统一异步执行,避免阻塞消息接收循环
|
||||
go w.routeCommand(cmdMsg)
|
||||
}
|
||||
} else {
|
||||
// 处理普通消息
|
||||
@@ -721,12 +760,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
return
|
||||
}
|
||||
if cmdMsg.Type != "call" {
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
}
|
||||
// 所有命令统一异步执行,避免阻塞消息接收循环
|
||||
go w.routeCommand(cmdMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1400,30 +1435,7 @@ func (w *WebSocketReporter) sendResponse(response CommandResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
var messageData []byte
|
||||
|
||||
// 如果有加密器,则加密数据
|
||||
if w.aesCrypto != nil {
|
||||
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 加密响应失败,发送原始数据: %v\n", err)
|
||||
messageData = jsonData
|
||||
} else {
|
||||
// 创建加密消息包装器
|
||||
encryptedMessage := map[string]interface{}{
|
||||
"encrypted": true,
|
||||
"data": encryptedData,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
messageData, err = json.Marshal(encryptedMessage)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 序列化加密响应失败,发送原始数据: %v\n", err)
|
||||
messageData = jsonData
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messageData = jsonData
|
||||
}
|
||||
messageData := w.encryptPayload(jsonData)
|
||||
|
||||
// 检查消息大小,如果超过10MB则记录警告
|
||||
if len(messageData) > 10*1024*1024 {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 050 - 监控页面隧道独立入口
|
||||
|
||||
## 背景
|
||||
当前监控页面的隧道流量监控嵌套在节点详情内,用户需要先点击某个节点才能看到隧道流量。
|
||||
需要将隧道监控提升为与节点列表同级的入口,展示隧道质量(延迟)和流量统计。
|
||||
|
||||
## 设计
|
||||
|
||||
### 监控页面结构变更
|
||||
在 `monitor.tsx` 页面增加 Tab 式结构(或并列区域),新增"隧道监控"入口与节点列表同级:
|
||||
- **节点** Tab:保持现有节点卡片/列表 + 详情视图
|
||||
- **隧道** Tab:新增隧道卡片/列表视图
|
||||
- 每个隧道卡片显示:隧道名称、状态、质量指标(入口→出口延迟、出口→Bing TCP延迟)
|
||||
- 点击隧道进入详情:隧道流量趋势图表(已有的 tunnel metrics chart)
|
||||
|
||||
### 隧道质量指标
|
||||
使用已有的隧道诊断 API (`/tunnel/diagnose`) 进行 TCP 延迟测试:
|
||||
- 入口→出口延迟
|
||||
- 出口→Bing TCP 延迟(bing.com:443)
|
||||
|
||||
新增一个轻量级的后端 API 来获取隧道质量快照(定时 TCP 测试延迟),
|
||||
或者前端在隧道 Tab 里手动触发诊断。
|
||||
|
||||
### 实现方案(前端为主)
|
||||
由于后端已有完整的 tunnel metrics 和 diagnosis API,本次改动主要在前端:
|
||||
|
||||
1. `monitor.tsx` 增加 Tab 切换(节点 / 隧道)
|
||||
2. 新建 `tunnel-monitor-view.tsx` 组件
|
||||
3. 隧道列表用卡片展示质量指标
|
||||
4. 点击进入隧道详情,展示流量图表
|
||||
5. 从 `monitor-view.tsx` 的节点详情中移除隧道流量图表
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 1. 修改 `monitor.tsx`,增加节点/隧道 Tab 切换
|
||||
- [x] 2. 新建 `tunnel-monitor-view.tsx` 组件,实现隧道列表视图
|
||||
- [x] 3. 隧道卡片展示名称、状态、质量指标(支持手动诊断获取延迟)
|
||||
- [x] 4. 隧道详情视图,展示流量趋势图表(复用已有 tunnel metrics API)
|
||||
- [x] 5. 从节点详情视图中移除隧道流量图表
|
||||
- [x] 6. TypeScript 编译通过验证
|
||||
@@ -0,0 +1,15 @@
|
||||
# Plan 051: Sync Main and Push Tag
|
||||
|
||||
## Goal
|
||||
Submit all changes, push them to the remote repository, create a pull request to main, merge it, and push a new tag.
|
||||
|
||||
## Tasks
|
||||
- [ ] Determine the next tag version.
|
||||
- [ ] Add all changes to the staging area.
|
||||
- [ ] Commit the changes.
|
||||
- [ ] Push changes to the current branch.
|
||||
- [ ] Create a pull request via `gh` CLI.
|
||||
- [ ] Merge the pull request via `gh` CLI.
|
||||
- [ ] Sync the `main` branch locally.
|
||||
- [ ] Create a new tag.
|
||||
- [ ] Push the new tag to the remote repository.
|
||||
@@ -0,0 +1,10 @@
|
||||
# 052 Node and User List View
|
||||
|
||||
## Objective
|
||||
Add a list view and a card view toggle for Node Page and User Page, so they match exactly how the Monitor Page does it.
|
||||
|
||||
## Tasks
|
||||
- [x] Add `viewMode` state toggle with `LayoutGrid` and `List` icons to `pages/node.tsx` header area.
|
||||
- [x] Implement Table view rendering for Node list in `pages/node.tsx`.
|
||||
- [x] Add `viewMode` state toggle with `LayoutGrid` and `List` icons to `pages/user.tsx` header area.
|
||||
- [x] Implement Table view rendering for User list in `pages/user.tsx`.
|
||||
@@ -0,0 +1,16 @@
|
||||
# 053 List View Styling
|
||||
|
||||
## Objective
|
||||
Update the design of Node and User list table views to match the provided screenshot:
|
||||
- Use colored dots for status instead of separate "Status" text columns.
|
||||
- The action buttons should have text labels (e.g. "安装", "编辑") with `variant="flat"` instead of icons.
|
||||
- Add version column for Node list.
|
||||
- Remove traffic columns from Node list as requested.
|
||||
- Adjust User list to match this clean style.
|
||||
|
||||
## Tasks
|
||||
- [x] Update `pages/node.tsx` list view to use the new column layout (Node name with dot, Address, Version, Actions).
|
||||
- [x] Update action buttons in `pages/node.tsx` list view to use text instead of icons.
|
||||
- [x] Update `pages/user.tsx` list view to use the status dot pattern.
|
||||
- [x] Update action buttons in `pages/user.tsx` list view to use text instead of icons.
|
||||
- [x] Ensure `selectionMode="multiple"` (or similar) is properly reflected.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Agent-Panel 通信优化:提升稳定性与效率
|
||||
|
||||
## 背景
|
||||
|
||||
Agent(`go-gost/x/socket/websocket_reporter.go`)与 Panel(`go-backend/internal/ws/server.go`)之间通过 WebSocket 进行实时通信,包括指标上报(每 5s)、命令下发/响应、和流量上报(HTTP)。经过代码审查,以下是发现的问题和优化建议。
|
||||
|
||||
---
|
||||
|
||||
## 发现的问题
|
||||
|
||||
### 1. Keepalive 时序不匹配 —— 导致误断连
|
||||
|
||||
| 参数 | Agent 侧 | Panel 侧 |
|
||||
|------|----------|----------|
|
||||
| Read deadline | `reporterReadWait` = 60s | `wsPongWait` = 45s |
|
||||
| Ping 发送间隔 | 无主动 ping(靠指标数据 5s 续命) | `wsPingPeriod` = 15s |
|
||||
| Write timeout | `reporterWriteWait` = 5s | `wsWriteWait` = 5s |
|
||||
|
||||
**问题**:Panel 每 15s 发 ping,Agent read deadline 60s,但 Panel pong deadline 只有 45s。如果 Agent 的指标消息被延迟(网络抖动),Panel 可能因 pong 超时而关闭连接。两侧的超时参数缺乏协调设计。
|
||||
|
||||
### 2. 固定重连间隔 —— 无退避策略
|
||||
|
||||
Agent 断线后以固定 5s 间隔重试(`reconnectTime = 5 * time.Second`),在 Panel 长时间不可用(升级、网络故障)的情况下,会产生大量无用连接尝试。
|
||||
|
||||
### 3. Panel 侧每次解密都重建 AES 加密器
|
||||
|
||||
`ws/server.go` 的 `decryptIfNeeded()` 和 `SendCommand()` 每次调用都 `security.NewAESCrypto(secret)` 重新创建 cipher(SHA256 + AES-GCM 初始化),对于高频指标消息(5s/次 × N 节点),有不必要的 CPU 开销。
|
||||
|
||||
### 4. 指标消息使用 JSON Text 格式传输
|
||||
|
||||
每 5s 发送一次包含 13 个字段的 SystemInfo JSON,加密后还需 base64 编码,一条消息约 300-500 bytes(加密后约 700 bytes)。对于大量节点场景,存在优化空间。
|
||||
|
||||
### 5. `receiveMessages` 紧循环中有频繁锁竞争
|
||||
|
||||
`receiveMessages()` 在每次 `ReadMessage()` 前都要 `Lock/Unlock connMutex` 检查连接状态,但 `ReadMessage` 本身是阻塞的,实际不需要在循环外检查。
|
||||
|
||||
### 6. 状态变更命令阻塞读消息循环
|
||||
|
||||
`routeCommand` 中的 Service/Chain/Limiter CRUD 命令是同步执行的,包括 `saveConfig()` 文件写入。执行期间会阻塞 `receiveMessages` 的读取循环。
|
||||
|
||||
---
|
||||
|
||||
## 推荐的优化方案(按优先级排列)
|
||||
|
||||
### P0 — 高收益、低风险
|
||||
|
||||
#### 优化 1:协调 Keepalive 参数
|
||||
|
||||
**文件**:`websocket_reporter.go`
|
||||
|
||||
- Agent 增加独立的 WebSocket ping 发送(每 20s),不依赖指标数据来维持连接
|
||||
- 统一 read deadline 设置,确保两侧 read timeout > 2×ping interval
|
||||
|
||||
#### 优化 2:指数退避重连
|
||||
|
||||
**文件**:`websocket_reporter.go`
|
||||
|
||||
- 初始间隔 2s,按指数退避增长至最大 2 分钟
|
||||
- 连接成功后立即重置退避
|
||||
- 增加随机抖动(jitter)避免大量 Agent 同时重连
|
||||
|
||||
#### 优化 3:Panel 侧缓存 AES 加密器
|
||||
|
||||
**文件**:`ws/server.go`
|
||||
|
||||
- 将 `AESCrypto` 实例缓存在 `nodeSession` 中,避免每条消息重建
|
||||
- `SendCommand` 复用缓存实例
|
||||
|
||||
### P1 — 中等收益
|
||||
|
||||
#### 优化 4:减少 `receiveMessages` 锁竞争
|
||||
|
||||
**文件**:`websocket_reporter.go`
|
||||
|
||||
- 将连接状态检查移到循环外,只在出错/关闭时通过 channel 通知退出
|
||||
- 用 `context.WithCancel` 代替锁检查 `connected` flag 来控制生命周期
|
||||
|
||||
#### 优化 5:异步化状态变更命令处理
|
||||
|
||||
**文件**:`websocket_reporter.go`
|
||||
|
||||
- 所有命令统一异步执行(通过 goroutine + response channel),避免阻塞 readLoop
|
||||
- 当前只有 TcpPing/ServiceMonitorCheck/UpgradeAgent/RollbackAgent 是异步的
|
||||
|
||||
---
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### Agent 侧 (`go-gost/x/socket`)
|
||||
|
||||
---
|
||||
|
||||
#### [MODIFY] [websocket_reporter.go](file:///Users/sagit/Documents/github/flvx/go-gost/x/socket/websocket_reporter.go)
|
||||
|
||||
1. **指数退避重连**:将 `reconnectTime` 从固定 `5s` 改为动态退避字段,增加 `curBackoff/maxBackoff` 字段
|
||||
2. **独立 Ping 发送**:在 `handleConnection()` 中增加 WebSocket ping ticker(20s),独立于指标上报
|
||||
3. **减少锁竞争**:`receiveMessages` 中只在循环入口检查一次连接,此后靠 `ReadMessage` 的 error 退出
|
||||
4. **统一命令异步化**:所有 `routeCommand` 调用统一使用 goroutine
|
||||
|
||||
---
|
||||
|
||||
### Panel 侧 (`go-backend/internal/ws`)
|
||||
|
||||
---
|
||||
|
||||
#### [MODIFY] [server.go](file:///Users/sagit/Documents/github/flvx/go-backend/internal/ws/server.go)
|
||||
|
||||
1. **缓存 AES 加密器**:在 `nodeSession` 中增加 `crypto *security.AESCrypto` 字段,节点连接时初始化
|
||||
2. **`decryptIfNeeded` 接收 crypto 参数**而非 secret 字符串
|
||||
3. **`SendCommand` 使用缓存 crypto** 实例
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
|
||||
```bash
|
||||
# 运行现有 agent 侧单元测试(验证不回归)
|
||||
(cd go-gost/x && go test ./socket/... -v -count=1)
|
||||
|
||||
# 运行现有流量上报测试
|
||||
(cd go-gost/x && go test ./service/... -v -count=1)
|
||||
|
||||
# 运行 panel 侧全部测试
|
||||
(cd go-backend && go test ./... -count=1)
|
||||
```
|
||||
|
||||
### Manual Verification
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 本次改动涉及实时通信核心路径,建议在 staging 环境部署后观察至少 30 分钟:
|
||||
> 1. 检查节点在面板中状态是否正常显示为在线
|
||||
> 2. 手动停止面板后观察 Agent 日志,确认重连间隔呈指数增长
|
||||
> 3. 恢复面板后确认 Agent 能自动恢复连接并恢复指标上报
|
||||
> 4. 通过面板下发命令(如添加/删除 service),确认命令执行成功
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 优化 1:Agent 增加独立 WebSocket ping 发送
|
||||
- [x] 优化 2:Agent 指数退避重连
|
||||
- [x] 优化 3:Panel 缓存 AES 加密器
|
||||
- [x] 优化 4:Agent 减少 receiveMessages 锁竞争
|
||||
- [x] 优化 5:Agent 命令处理统一异步化
|
||||
- [x] 运行现有测试验证不回归
|
||||
@@ -0,0 +1,31 @@
|
||||
# 055 - 隧道质量定时探测 + 实时展示 + 历史图表
|
||||
|
||||
## 背景
|
||||
当前隧道质量检测是手动触发的:用户点击"诊断"按钮 → 后端调用节点 TcpPing → 返回结果。
|
||||
需求:改为**后端定时(每10秒)自动探测**所有启用隧道的质量(入口→出口延迟、出口→Bing延迟),
|
||||
结果保留历史(24h),前端隧道 Tab 实时展示 + 图表历史趋势。
|
||||
|
||||
## 设计原则:与服务监控复用
|
||||
|
||||
| 复用点 | 服务监控 | 隧道质量 |
|
||||
|--------|---------|---------|
|
||||
| 调度方式 | `health.Checker.Start(ctx)` via `jobs.go` | `tunnelQualityProber.Start(ctx)` via `jobs.go` |
|
||||
| 存储模式 | `service_monitor_result` (history, insert) | `tunnel_quality` (history, insert) |
|
||||
| 清理方式 | `PruneServiceMonitorResults(olderThanMs)` | `PruneTunnelQualityResults(olderThanMs)` |
|
||||
| 最新查询 | `GetLatestServiceMonitorResults()` (window func) | `GetLatestTunnelQualities()` (window func) |
|
||||
| 历史查询 | `GetServiceMonitorResults(id, limit)` | `GetTunnelQualityHistory(id, start, end)` |
|
||||
| API 模式 | `GET /monitor/services/{id}/results` | `GET /monitor/tunnels/{id}/quality` |
|
||||
| 前端图表 | Recharts LineChart (延迟趋势) | Recharts LineChart (同样模式) |
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 1. `TunnelQuality` model 改为历史存储(composite index, 非 unique)
|
||||
- [x] 2. Repo 改为 insert(非 upsert),复用服务监控的查询模式
|
||||
- [x] 3. 添加 `PruneTunnelQualityResults` + `GetLatestTunnelQualities` + `GetTunnelQualityHistory`
|
||||
- [x] 4. Prober 生命周期集成到 `jobs.go`(与 healthCheck 同级)
|
||||
- [x] 5. Prober 添加 24h 清理周期
|
||||
- [x] 6. 添加 API `GET /monitor/tunnels/{id}/quality` 返回历史
|
||||
- [x] 7. 前端添加 `getMonitorTunnelQualityHistory()` API
|
||||
- [x] 8. 前端详情页添加质量趋势图表(复用服务监控图表组件模式)
|
||||
- [x] 9. Go 编译 + 测试通过
|
||||
- [x] 10. TypeScript 编译通过
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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` 模式
|
||||
@@ -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秒上报"
|
||||
@@ -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.
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="FLVX" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap" rel="stylesheet">
|
||||
|
||||
<title>FLVX</title>
|
||||
<script>
|
||||
@@ -82,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 {
|
||||
@@ -94,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';
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
MonitorTunnelApiItem,
|
||||
MonitorPermissionApiItem,
|
||||
MonitorAccessApiData,
|
||||
TunnelQualityApiItem,
|
||||
} from "./types";
|
||||
|
||||
import axios from "axios";
|
||||
@@ -451,6 +452,25 @@ export const getTunnelMetrics = (
|
||||
export const getMonitorTunnels = () =>
|
||||
Network.get<MonitorTunnelApiItem[]>("/monitor/tunnels");
|
||||
|
||||
export const getMonitorTunnelQuality = () =>
|
||||
Network.get<TunnelQualityApiItem[]>("/monitor/tunnels/quality");
|
||||
|
||||
export const getMonitorTunnelQualityHistory = (
|
||||
tunnelId: number,
|
||||
start?: number,
|
||||
end?: number,
|
||||
) => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (start) params.start = String(start);
|
||||
if (end) params.end = String(end);
|
||||
|
||||
return Network.get<TunnelQualityApiItem[]>(
|
||||
`/monitor/tunnels/${tunnelId}/quality`,
|
||||
params,
|
||||
);
|
||||
};
|
||||
|
||||
export const getServiceMonitorList = () =>
|
||||
Network.get<ServiceMonitorApiItem[]>("/monitor/services");
|
||||
|
||||
@@ -466,11 +486,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[]>(
|
||||
|
||||
@@ -487,3 +487,14 @@ export interface MonitorAccessApiData {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface TunnelQualityApiItem {
|
||||
tunnelId: number;
|
||||
entryToExitLatency: number;
|
||||
exitToBingLatency: number;
|
||||
entryToExitLoss: number;
|
||||
exitToBingLoss: number;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -1110,7 +1110,7 @@ export default function DashboardPage() {
|
||||
key={item.id}
|
||||
className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg"
|
||||
>
|
||||
<code className="text-sm flex-1 mr-3 text-foreground">
|
||||
<code className="font-mono text-sm flex-1 mr-3 text-foreground">
|
||||
{item.address}
|
||||
</code>
|
||||
<Button
|
||||
|
||||
@@ -66,12 +66,12 @@ export const AnnouncementBanner = ({
|
||||
</ol>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
|
||||
<code className="font-mono rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-2 overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
|
||||
<pre className="mb-2 font-mono overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
|
||||
@@ -4977,7 +4977,7 @@ export default function ForwardPage() {
|
||||
key={item.id}
|
||||
className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg"
|
||||
>
|
||||
<code className="text-sm flex-1 mr-3 text-foreground">
|
||||
<code className="font-mono text-sm flex-1 mr-3 text-foreground">
|
||||
{item.address}
|
||||
</code>
|
||||
<Button
|
||||
|
||||
@@ -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,13 +2,14 @@ import type { MonitorNodeApiItem } from "@/api/types";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { RefreshCw, LayoutGrid, List } from "lucide-react";
|
||||
import { RefreshCw, LayoutGrid, List, Server, ArrowRightLeft } from "lucide-react";
|
||||
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { getMonitorNodes } from "@/api";
|
||||
import { MonitorView } from "@/pages/node/monitor-view";
|
||||
import { TunnelMonitorView } from "@/pages/node/tunnel-monitor-view";
|
||||
|
||||
type MonitorNode = {
|
||||
id: number;
|
||||
@@ -16,11 +17,14 @@ type MonitorNode = {
|
||||
connectionStatus: "online" | "offline";
|
||||
};
|
||||
|
||||
type MonitorTab = "nodes" | "tunnels";
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
|
||||
const [nodesLoading, setNodesLoading] = useState(false);
|
||||
const [nodesError, setNodesError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
|
||||
|
||||
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
@@ -81,7 +85,7 @@ export default function MonitorPage() {
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold truncate">监控</h2>
|
||||
<div className="text-xs text-default-500 truncate">
|
||||
实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP)
|
||||
实时节点状态 + 隧道质量检测 + 历史指标图表 + 服务监控(TCP/ICMP)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -93,19 +97,47 @@ export default function MonitorPage() {
|
||||
>
|
||||
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={nodesLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => loadNodes()}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新节点
|
||||
</Button>
|
||||
{activeTab === "nodes" && (
|
||||
<Button
|
||||
isLoading={nodesLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => loadNodes()}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新节点
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nodesError ? (
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex items-center gap-1 p-1 rounded-xl bg-default-100 dark:bg-default-50/10 w-fit">
|
||||
<button
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
activeTab === "nodes"
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-default-500 hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setActiveTab("nodes")}
|
||||
>
|
||||
<Server className="w-4 h-4" />
|
||||
节点
|
||||
</button>
|
||||
<button
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
activeTab === "tunnels"
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-default-500 hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setActiveTab("tunnels")}
|
||||
>
|
||||
<ArrowRightLeft className="w-4 h-4" />
|
||||
隧道
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{nodesError && activeTab === "nodes" ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-sm font-semibold">节点列表</h3>
|
||||
@@ -117,7 +149,11 @@ export default function MonitorPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
|
||||
{activeTab === "nodes" ? (
|
||||
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
|
||||
) : (
|
||||
<TunnelMonitorView viewMode={viewMode} />
|
||||
)}
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { LayoutGrid, List } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -292,6 +301,10 @@ export default function NodePage() {
|
||||
"node-active-tab",
|
||||
"local",
|
||||
);
|
||||
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
|
||||
"node-view-mode",
|
||||
"grid",
|
||||
);
|
||||
|
||||
// Backward-compat: older versions stored extra tab values.
|
||||
useEffect(() => {
|
||||
@@ -596,6 +609,8 @@ export default function NodePage() {
|
||||
|
||||
if (!metric || typeof metric !== "object") return node;
|
||||
|
||||
const incomingUptime = metric.uptime ?? 0;
|
||||
|
||||
return {
|
||||
...node,
|
||||
connectionStatus: "online",
|
||||
@@ -607,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,
|
||||
@@ -1394,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;
|
||||
|
||||
@@ -1639,6 +1647,21 @@ export default function NodePage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 视图切换按钮 */}
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="flat"
|
||||
className="text-default-600 hidden sm:flex"
|
||||
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
|
||||
>
|
||||
{viewMode === "list" ? (
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
) : (
|
||||
<List className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 筛选按钮 */}
|
||||
<Button
|
||||
isIconOnly
|
||||
@@ -1738,6 +1761,159 @@ export default function NodePage() {
|
||||
: "暂无本地节点,点击上方按钮开始创建"
|
||||
}
|
||||
/>
|
||||
) : 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 className="w-12 px-4 whitespace-nowrap overflow-hidden">
|
||||
<Checkbox
|
||||
isSelected={selectMode && selectedIds.size === displayNodes.length && displayNodes.length > 0}
|
||||
onValueChange={(checked) => {
|
||||
if (checked) {
|
||||
selectAll();
|
||||
setSelectMode(true);
|
||||
} else {
|
||||
deselectAll();
|
||||
setSelectMode(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableColumn>
|
||||
<TableColumn>节点名称</TableColumn>
|
||||
<TableColumn>地址</TableColumn>
|
||||
<TableColumn>版本</TableColumn>
|
||||
<TableColumn>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={displayNodes}>
|
||||
{(node) => {
|
||||
const isRemoteNode = node.isRemote === 1;
|
||||
const hasRemark = Boolean(node.remark?.trim());
|
||||
|
||||
return (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell className="px-4">
|
||||
<Checkbox
|
||||
isSelected={selectedIds.has(node.id)}
|
||||
onValueChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectMode(true);
|
||||
setSelectedIds((prev) => new Set([...prev, node.id]));
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(node.id);
|
||||
if (next.size === 0) setSelectMode(false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||
node.connectionStatus === "online"
|
||||
? "bg-success"
|
||||
: node.syncError
|
||||
? "bg-danger"
|
||||
: "bg-default-400"
|
||||
} ${node.systemInfo && node.connectionStatus === "online" ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{node.name}
|
||||
</span>
|
||||
{hasRemark && (
|
||||
<Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
|
||||
{node.remark}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono text-default-600">
|
||||
{isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-default-500">
|
||||
{node.version || "-"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||
{!isRemoteNode && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
|
||||
isLoading={node.copyLoading}
|
||||
onPress={() => openInstallSelector(node)}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
)}
|
||||
{!isRemoteNode && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
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"
|
||||
isDisabled={node.connectionStatus !== "online"}
|
||||
isLoading={node.upgradeLoading}
|
||||
onPress={() => {
|
||||
setUpgradeTargetNodeId(node.id);
|
||||
openUpgradeModal("single");
|
||||
}}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
)}
|
||||
{!isRemoteNode && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
className="h-6 px-2 min-w-0 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/30 dark:text-blue-400"
|
||||
isDisabled={node.connectionStatus !== "online"}
|
||||
isLoading={node.rollbackLoading}
|
||||
onPress={() => handleRollbackNode(node)}
|
||||
>
|
||||
回退
|
||||
</Button>
|
||||
)}
|
||||
{!isRemoteNode && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
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"
|
||||
onPress={() => handleEdit(node)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
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"
|
||||
onPress={() => handleDelete(node)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
@@ -1773,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">
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
MonitorTunnelApiItem,
|
||||
NodeMetricApiItem,
|
||||
TunnelMetricApiItem,
|
||||
ServiceMonitorApiItem,
|
||||
ServiceMonitorResultApiItem,
|
||||
ServiceMonitorMutationPayload,
|
||||
@@ -34,9 +32,7 @@ import {
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
getMonitorTunnels,
|
||||
getNodeMetrics,
|
||||
getTunnelMetrics,
|
||||
getServiceMonitorList,
|
||||
getServiceMonitorLimits,
|
||||
getServiceMonitorResults,
|
||||
@@ -268,9 +264,9 @@ type MetricType =
|
||||
const METRICS_MAX_ROWS = 5000;
|
||||
|
||||
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
|
||||
checkerScanIntervalSec: 30,
|
||||
minIntervalSec: 30,
|
||||
defaultIntervalSec: 60,
|
||||
checkerScanIntervalSec: 1,
|
||||
minIntervalSec: 1,
|
||||
defaultIntervalSec: 1,
|
||||
minTimeoutSec: 1,
|
||||
defaultTimeoutSec: 5,
|
||||
maxTimeoutSec: 60,
|
||||
@@ -287,17 +283,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
|
||||
const [metricsRangeMs, setMetricsRangeMs] = useState(60 * 60 * 1000);
|
||||
|
||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||
const [, setTunnelsError] = useState<string | null>(null);
|
||||
const [selectedTunnelId, setSelectedTunnelId] = useState<number | null>(null);
|
||||
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
|
||||
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
|
||||
const [, setTunnelMetricsTruncated] = useState(false);
|
||||
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
|
||||
|
||||
|
||||
const [serviceMonitors, setServiceMonitors] = useState<
|
||||
ServiceMonitorApiItem[]
|
||||
@@ -327,6 +313,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
enabled: true,
|
||||
});
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [activeServiceMonitorId, setActiveServiceMonitorId] = useState<number | null>(null);
|
||||
const [serviceMonitorRangeMs, setServiceMonitorRangeMs] = useState(60 * 60 * 1000);
|
||||
|
||||
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
||||
const [resultsModalOpen, setResultsModalOpen] = useState(false);
|
||||
@@ -398,27 +386,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",
|
||||
@@ -446,111 +438,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
}
|
||||
}, [preferredNodeId, selectedNodeId]);
|
||||
|
||||
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setTunnelsLoading(true);
|
||||
try {
|
||||
const response = await getMonitorTunnels();
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
setAccessDenied(null);
|
||||
setTunnelsError(null);
|
||||
setTunnels(response.data);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.code === 403) {
|
||||
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
|
||||
setTunnelsError(null);
|
||||
setTunnels([]);
|
||||
|
||||
return;
|
||||
}
|
||||
setTunnelsError(response.msg || "加载隧道列表失败");
|
||||
if (!silent) toast.error(response.msg || "加载隧道列表失败");
|
||||
} catch {
|
||||
if (!silent) {
|
||||
setTunnelsError("加载隧道列表失败");
|
||||
toast.error("加载隧道列表失败");
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setTunnelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTunnels();
|
||||
}, [loadTunnels]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTunnels({ silent: true });
|
||||
}, 60_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadTunnels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTunnelId && tunnels.length > 0) {
|
||||
setSelectedTunnelId(tunnels[0].id);
|
||||
}
|
||||
}, [selectedTunnelId, tunnels]);
|
||||
|
||||
const loadTunnelMetrics = useCallback(
|
||||
async (tunnelId: number, options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setTunnelMetricsLoading(true);
|
||||
try {
|
||||
const end = Date.now();
|
||||
const start = end - tunnelRangeMs;
|
||||
const response = await getTunnelMetrics(tunnelId, start, end);
|
||||
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
setAccessDenied(null);
|
||||
setTunnelMetricsError(null);
|
||||
setTunnelMetricsTruncated(response.data.length >= METRICS_MAX_ROWS);
|
||||
const ordered = [...response.data].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
|
||||
setTunnelMetrics(ordered);
|
||||
|
||||
return;
|
||||
}
|
||||
if (response.code === 403) {
|
||||
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
|
||||
setTunnelMetricsTruncated(false);
|
||||
setTunnelMetricsError(null);
|
||||
|
||||
return;
|
||||
}
|
||||
setTunnelMetricsTruncated(false);
|
||||
setTunnelMetricsError(response.msg || "加载隧道指标失败");
|
||||
if (!silent) toast.error(response.msg || "加载隧道指标失败");
|
||||
} catch {
|
||||
setTunnelMetricsTruncated(false);
|
||||
if (!silent) setTunnelMetricsError("加载隧道指标失败");
|
||||
} finally {
|
||||
if (!silent) setTunnelMetricsLoading(false);
|
||||
}
|
||||
},
|
||||
[tunnelRangeMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTunnelId) {
|
||||
void loadTunnelMetrics(selectedTunnelId);
|
||||
}
|
||||
}, [selectedTunnelId, loadTunnelMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTunnelId) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTunnelMetrics(selectedTunnelId, { silent: true });
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [selectedTunnelId, loadTunnelMetrics]);
|
||||
|
||||
const loadMetrics = useCallback(
|
||||
async (nodeId: number, options?: { silent?: boolean }) => {
|
||||
@@ -642,9 +530,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) => ({
|
||||
@@ -716,7 +607,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);
|
||||
}
|
||||
@@ -732,7 +623,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
const timer = window.setInterval(() => {
|
||||
void loadServiceMonitors({ silent: true });
|
||||
void loadLatestMonitorResults();
|
||||
}, 30_000);
|
||||
}, 1_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadLatestMonitorResults, loadServiceMonitors]);
|
||||
@@ -757,6 +648,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
void loadResultsForModal();
|
||||
}, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]);
|
||||
|
||||
// Reload results for the active service monitor chart when time range changes
|
||||
useEffect(() => {
|
||||
if (!activeServiceMonitorId) return;
|
||||
void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs });
|
||||
}, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]);
|
||||
|
||||
const chartData = metrics.map((m) => ({
|
||||
time: formatTimestamp(m.timestamp, metricsRangeMs),
|
||||
cpu: m.cpuUsage,
|
||||
@@ -816,21 +713,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const tunnelChartData = tunnelMetrics.map((m) => ({
|
||||
time: formatTimestamp(m.timestamp, tunnelRangeMs),
|
||||
bytesIn: m.bytesIn,
|
||||
bytesOut: m.bytesOut,
|
||||
}));
|
||||
|
||||
const getTunnelChartConfig = () => {
|
||||
return {
|
||||
lines: [
|
||||
{ dataKey: "bytesIn", color: "#10b981", name: "入站流量" },
|
||||
{ dataKey: "bytesOut", color: "#ef4444", name: "出站流量" },
|
||||
],
|
||||
yAxisLabel: "流量",
|
||||
};
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (monitor?: ServiceMonitorApiItem) => {
|
||||
if (monitor) {
|
||||
@@ -1091,7 +974,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
}, [getLatestResult, isResultStale, serviceMonitors]);
|
||||
|
||||
const chartConfig = getChartConfig();
|
||||
const tunnelChartConfig = getTunnelChartConfig();
|
||||
|
||||
const nodeYAxisTickFormatter = (value: unknown) => {
|
||||
const n = Number(value);
|
||||
@@ -1131,21 +1013,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
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 detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null;
|
||||
const detailRealtimeMetric = detailNodeId != null ? realtimeNodeMetrics[detailNodeId] || null : null;
|
||||
@@ -1204,7 +1072,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>
|
||||
@@ -1223,7 +1099,7 @@ 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>
|
||||
@@ -1440,158 +1316,155 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Tunnel traffic 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-56"
|
||||
placeholder={tunnelsLoading ? "加载中..." : "选择隧道"}
|
||||
selectedKeys={selectedTunnelId ? [String(selectedTunnelId)] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const tunnelId = Number(Array.from(keys)[0]);
|
||||
setSelectedTunnelId(tunnelId || null);
|
||||
}}
|
||||
>
|
||||
{tunnels.map((t) => (
|
||||
<SelectItem key={String(t.id)}>{t.name}{t.status === 1 ? "" : " (禁用)"}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
className="w-36"
|
||||
selectedKeys={[String(tunnelRangeMs)]}
|
||||
onSelectionChange={(keys) => {
|
||||
const v = Number(Array.from(keys)[0]);
|
||||
if (v > 0) setTunnelRangeMs(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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{selectedTunnelId ? (
|
||||
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>
|
||||
) : tunnelMetrics.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={tunnelChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" fontSize={12} />
|
||||
<YAxis fontSize={12} tickFormatter={tunnelYAxisTickFormatter} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
formatter={tunnelTooltipFormatter}
|
||||
/>
|
||||
{tunnelChartConfig.lines.map((line) => (
|
||||
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Service monitors chart – same style as node metrics */}
|
||||
{(() => {
|
||||
// Resolve which monitor is active
|
||||
const resolvedActiveMonitor =
|
||||
detailServiceMonitors.find((m) => m.id === activeServiceMonitorId) ||
|
||||
detailServiceMonitors[0] ||
|
||||
null;
|
||||
const resolvedActiveMonitorId = resolvedActiveMonitor?.id ?? null;
|
||||
const activeLatestResult = resolvedActiveMonitorId != null ? getLatestResult(resolvedActiveMonitorId) : null;
|
||||
const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false;
|
||||
const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : [];
|
||||
const activeLatencyData = [...activeResults]
|
||||
.map((r) => ({
|
||||
time: formatTimestamp(r.timestamp, serviceMonitorRangeMs),
|
||||
latency: r.success === 1 ? r.latencyMs : null,
|
||||
success: r.success,
|
||||
}));
|
||||
|
||||
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(serviceMonitorRangeMs)]}
|
||||
onSelectionChange={(keys) => {
|
||||
const v = Number(Array.from(keys)[0]);
|
||||
if (v > 0) setServiceMonitorRangeMs(v);
|
||||
}}
|
||||
>
|
||||
<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, { rangeMs: serviceMonitorRangeMs });
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
<Button color="primary" size="sm" variant="flat" onPress={() => handleOpenEditModal()}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加监控
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-default-500">暂无指标数据</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center py-8 text-default-500">请选择一个隧道查看指标</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-4">
|
||||
{monitorsLoading ? (
|
||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
||||
) : detailServiceMonitors.length > 0 ? (
|
||||
<>
|
||||
{/* Monitor switcher buttons – like metric type tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detailServiceMonitors.map((monitor) => {
|
||||
const lr = getLatestResult(monitor.id);
|
||||
const statusColor =
|
||||
monitor.enabled !== 1 ? "default" :
|
||||
!lr ? "default" :
|
||||
lr.success === 1 ? "success" : "danger";
|
||||
const isActive = monitor.id === resolvedActiveMonitorId;
|
||||
|
||||
{/* Service monitors with latency charts */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">服务监控</h3>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={() => handleOpenEditModal()}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加监控
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-4">
|
||||
{monitorsLoading ? (
|
||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
||||
) : detailServiceMonitors.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{detailServiceMonitors.map((monitor) => {
|
||||
const latestResult = getLatestResult(monitor.id);
|
||||
const stale = isResultStale(monitor, latestResult);
|
||||
const results = monitorResults[monitor.id] || [];
|
||||
const latencyData = [...results].reverse().map((r) => ({
|
||||
time: formatTimestamp(r.timestamp),
|
||||
latency: r.success === 1 ? r.latencyMs : null,
|
||||
success: r.success,
|
||||
}));
|
||||
return (
|
||||
<Button
|
||||
key={monitor.id}
|
||||
color={isActive ? "primary" : statusColor as any}
|
||||
size="sm"
|
||||
variant={isActive ? "solid" : "flat"}
|
||||
onPress={() => {
|
||||
setActiveServiceMonitorId(monitor.id);
|
||||
// Load results for this monitor if not loaded
|
||||
if (!monitorResults[monitor.id] || monitorResults[monitor.id].length <= 1) {
|
||||
void loadMonitorResults(monitor.id, { rangeMs: serviceMonitorRangeMs });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 mr-1 ${
|
||||
monitor.enabled !== 1 ? "bg-default-300" :
|
||||
!lr ? "bg-default-400" :
|
||||
lr.success === 1 ? (isActive ? "bg-white" : "bg-success") : (isActive ? "bg-white" : "bg-danger")
|
||||
}`} />
|
||||
{monitor.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<Card key={monitor.id} className="border border-default-200/60">
|
||||
<CardHeader className="pb-1 flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||
monitor.enabled !== 1 ? "bg-default-300" :
|
||||
!latestResult ? "bg-default-400" :
|
||||
latestResult.success === 1 ? "bg-success" : "bg-danger"
|
||||
}`} />
|
||||
<span className="font-semibold text-sm truncate">{monitor.name}</span>
|
||||
<Chip size="sm" color="primary" variant="flat">{monitor.type.toUpperCase()}</Chip>
|
||||
{stale ? <Chip size="sm" color="warning" variant="flat">陈旧</Chip> : null}
|
||||
{/* Active monitor info bar */}
|
||||
{resolvedActiveMonitor && (
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-default-50 dark:bg-default-100/30 border border-divider">
|
||||
<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">每秒测试,30秒上报</span>
|
||||
{activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? (
|
||||
<span className="font-mono text-xs font-semibold text-success">{activeLatestResult.latencyMs.toFixed(0)}ms</span>
|
||||
) : null}
|
||||
{activeStale ? <Chip size="sm" color="warning" variant="flat">陈旧</Chip> : null}
|
||||
{resolvedActiveMonitor.enabled !== 1 ? <Chip size="sm" color="default" variant="flat">已禁用</Chip> : null}
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm" variant="light"><MoreVertical className="w-4 h-4" /></Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem startContent={<Play className="w-4 h-4" />} onPress={() => handleRunMonitor(monitor.id)}>立即检查</DropdownItem>
|
||||
<DropdownItem startContent={<Activity className="w-4 h-4" />} onPress={() => openResultsModal(monitor.id)}>查看记录</DropdownItem>
|
||||
<DropdownItem startContent={<Edit className="w-4 h-4" />} onPress={() => handleOpenEditModal(monitor)}>编辑</DropdownItem>
|
||||
<DropdownItem className="text-danger" color="danger" startContent={<Trash2 className="w-4 h-4" />} onPress={() => handleDeleteMonitor(monitor.id)}>删除</DropdownItem>
|
||||
<DropdownItem startContent={<Play className="w-4 h-4" />} onPress={() => resolvedActiveMonitorId != null && handleRunMonitor(resolvedActiveMonitorId)}>立即检查</DropdownItem>
|
||||
<DropdownItem startContent={<Activity className="w-4 h-4" />} onPress={() => resolvedActiveMonitorId != null && openResultsModal(resolvedActiveMonitorId)}>查看记录</DropdownItem>
|
||||
<DropdownItem startContent={<Edit className="w-4 h-4" />} onPress={() => resolvedActiveMonitor && handleOpenEditModal(resolvedActiveMonitor)}>编辑</DropdownItem>
|
||||
<DropdownItem className="text-danger" color="danger" startContent={<Trash2 className="w-4 h-4" />} onPress={() => resolvedActiveMonitorId != null && handleDeleteMonitor(resolvedActiveMonitorId)}>删除</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0 space-y-2">
|
||||
<div className="flex items-center gap-3 text-xs text-default-500">
|
||||
<span className="font-mono">{monitor.target}</span>
|
||||
<span>间隔 {monitor.intervalSec}s</span>
|
||||
{latestResult && Number.isFinite(latestResult.latencyMs) ? (
|
||||
<span className="font-mono text-success">{latestResult.latencyMs.toFixed(0)}ms</span>
|
||||
) : null}
|
||||
</div>
|
||||
{latencyData.length > 0 ? (
|
||||
<div className="h-24">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={latencyData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(128,128,128,0.15)" />
|
||||
<XAxis dataKey="time" fontSize={10} tick={false} />
|
||||
<YAxis fontSize={10} tickFormatter={(v: number) => `${Math.round(v)}ms`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: "12px" }}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(0)}ms`, "延迟"]}
|
||||
/>
|
||||
<Line dataKey="latency" dot={false} stroke="#10b981" strokeWidth={1.5} type="monotone" connectNulls={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-default-400 py-2">暂无检查记录</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-default-500">暂无服务监控,点击"添加监控"创建</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart area – same h-64 as node metrics */}
|
||||
{activeLatencyData.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={activeLatencyData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" fontSize={12} />
|
||||
<YAxis fontSize={12} tickFormatter={(v: number) => `${Math.round(v)}ms`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(0)}ms`, "延迟"]}
|
||||
/>
|
||||
<Line dataKey="latency" dot={false} stroke="#10b981" strokeWidth={2} type="monotone" connectNulls={false} name="延迟" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-default-500">暂无检查记录</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-default-500">暂无服务监控,点击"添加监控"创建</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1675,7 +1548,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
import type {
|
||||
MonitorTunnelApiItem,
|
||||
TunnelMetricApiItem,
|
||||
TunnelQualityApiItem,
|
||||
} from "@/api/types";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import {
|
||||
RefreshCw,
|
||||
ArrowLeft,
|
||||
Activity,
|
||||
Zap,
|
||||
Globe,
|
||||
ArrowRightLeft,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
getMonitorTunnels,
|
||||
getTunnelMetrics,
|
||||
getMonitorTunnelQuality,
|
||||
getMonitorTunnelQualityHistory,
|
||||
} from "@/api";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
|
||||
interface TunnelMonitorViewProps {
|
||||
viewMode?: "list" | "grid";
|
||||
}
|
||||
|
||||
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
|
||||
|
||||
const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
||||
const date = new Date(ts);
|
||||
const includeDate = (rangeMs ?? 0) >= 24 * 60 * 60 * 1000;
|
||||
|
||||
if (includeDate) {
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
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 }) {
|
||||
if (loading) {
|
||||
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
|
||||
}
|
||||
if (value === undefined || value < 0) {
|
||||
return <span className="text-default-400">-</span>;
|
||||
}
|
||||
const ms = value.toFixed(0);
|
||||
let colorClass = "text-success";
|
||||
if (value > 200) colorClass = "text-danger";
|
||||
else if (value > 100) colorClass = "text-warning";
|
||||
else if (value > 50) colorClass = "text-primary";
|
||||
|
||||
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
|
||||
}
|
||||
|
||||
/** Animated pulse dot for live status */
|
||||
function LiveDot() {
|
||||
return (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-success" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
|
||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
|
||||
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
||||
|
||||
// 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);
|
||||
|
||||
// Detail view state
|
||||
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
|
||||
|
||||
// Quality history for chart (mirrors service monitor results)
|
||||
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>([]);
|
||||
const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false);
|
||||
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(null);
|
||||
const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000);
|
||||
|
||||
// Tunnel traffic metrics for chart
|
||||
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
|
||||
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
|
||||
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
|
||||
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
|
||||
|
||||
// --- Load tunnel list ---
|
||||
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setTunnelsLoading(true);
|
||||
try {
|
||||
const response = await getMonitorTunnels();
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
setAccessDenied(null);
|
||||
setTunnelsError(null);
|
||||
setTunnels(response.data);
|
||||
return;
|
||||
}
|
||||
if (response.code === 403) {
|
||||
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
|
||||
setTunnelsError(null);
|
||||
setTunnels([]);
|
||||
return;
|
||||
}
|
||||
setTunnelsError(response.msg || "加载隧道列表失败");
|
||||
if (!silent) toast.error(response.msg || "加载隧道列表失败");
|
||||
} catch {
|
||||
if (!silent) {
|
||||
setTunnelsError("加载隧道列表失败");
|
||||
toast.error("加载隧道列表失败");
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setTunnelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTunnels();
|
||||
}, [loadTunnels]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTunnels({ silent: true });
|
||||
}, 60_000);
|
||||
|
||||
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;
|
||||
if (!silent) setQualityLoading(true);
|
||||
try {
|
||||
const response = await getMonitorTunnelQuality();
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
const map: Record<number, TunnelQualityApiItem> = {};
|
||||
for (const q of response.data) {
|
||||
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
|
||||
} finally {
|
||||
if (!silent) setQualityLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadQuality();
|
||||
}, [loadQuality]);
|
||||
|
||||
useEffect(() => {
|
||||
qualityTimerRef.current = window.setInterval(() => {
|
||||
void loadQuality({ silent: true });
|
||||
}, QUALITY_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
if (qualityTimerRef.current) {
|
||||
window.clearInterval(qualityTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [loadQuality]);
|
||||
|
||||
// --- Load quality history for detail chart ---
|
||||
const loadQualityHistory = useCallback(
|
||||
async (tunnelId: number, options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setQualityHistoryLoading(true);
|
||||
try {
|
||||
const end = Date.now();
|
||||
const start = end - qualityRangeMs;
|
||||
const response = await getMonitorTunnelQualityHistory(tunnelId, start, end);
|
||||
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
setQualityHistoryError(null);
|
||||
setQualityHistory(response.data);
|
||||
return;
|
||||
}
|
||||
if (response.code === 403) {
|
||||
setAccessDenied(response.msg || "暂无监控权限");
|
||||
return;
|
||||
}
|
||||
setQualityHistoryError(response.msg || "加载质量历史失败");
|
||||
if (!silent) toast.error(response.msg || "加载质量历史失败");
|
||||
} catch {
|
||||
if (!silent) {
|
||||
setQualityHistoryError("加载质量历史失败");
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setQualityHistoryLoading(false);
|
||||
}
|
||||
},
|
||||
[qualityRangeMs],
|
||||
);
|
||||
|
||||
// --- Load tunnel traffic metrics for detail chart ---
|
||||
const loadTunnelMetrics = useCallback(
|
||||
async (tunnelId: number, options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setTunnelMetricsLoading(true);
|
||||
try {
|
||||
const end = Date.now();
|
||||
const start = end - tunnelRangeMs;
|
||||
const response = await getTunnelMetrics(tunnelId, start, end);
|
||||
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
setTunnelMetricsError(null);
|
||||
const ordered = [...response.data].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
setTunnelMetrics(ordered);
|
||||
return;
|
||||
}
|
||||
setTunnelMetricsError(response.msg || "加载流量数据失败");
|
||||
} catch {
|
||||
if (!silent) setTunnelMetricsError("加载流量数据失败");
|
||||
} finally {
|
||||
if (!silent) setTunnelMetricsLoading(false);
|
||||
}
|
||||
},
|
||||
[tunnelRangeMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailTunnelId) {
|
||||
void loadQualityHistory(detailTunnelId);
|
||||
void loadTunnelMetrics(detailTunnelId);
|
||||
}
|
||||
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
|
||||
|
||||
// Auto-refresh detail charts
|
||||
useEffect(() => {
|
||||
if (!detailTunnelId) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void loadQualityHistory(detailTunnelId, { silent: true });
|
||||
void loadTunnelMetrics(detailTunnelId, { silent: true });
|
||||
}, 30_000);
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// 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 detailTunnel = detailTunnelId != null
|
||||
? tunnels.find((t) => t.id === detailTunnelId)
|
||||
: null;
|
||||
|
||||
// Aggregate stats
|
||||
const tunnelStats = useMemo(() => {
|
||||
const enabled = tunnels.filter((t) => t.status === 1).length;
|
||||
|
||||
return { total: tunnels.length, enabled };
|
||||
}, [tunnels]);
|
||||
|
||||
// Last quality update timestamp
|
||||
const lastQualityUpdate = useMemo(() => {
|
||||
let latest = 0;
|
||||
for (const q of Object.values(qualityMap)) {
|
||||
if (q.timestamp > latest) latest = q.timestamp;
|
||||
}
|
||||
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
|
||||
// =====================
|
||||
|
||||
if (accessDenied) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-warning" />
|
||||
<h3 className="text-lg font-semibold">监控权限</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="text-sm text-default-600">{accessDenied}</div>
|
||||
<div className="text-xs text-default-500 mt-2">
|
||||
如需使用监控功能,请联系管理员在用户页面授予监控权限。
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== DETAIL VIEW =====
|
||||
if (detailTunnelId && detailTunnel) {
|
||||
const quality = qualityMap[detailTunnelId];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button size="sm" variant="flat" onPress={() => {
|
||||
setDetailTunnelId(null);
|
||||
setQualityHistory([]);
|
||||
setTunnelMetrics([]);
|
||||
}}>
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
返回隧道列表
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRightLeft className={`w-5 h-5 ${detailTunnel.status === 1 ? "text-success" : "text-default-400"}`} />
|
||||
<h3 className="text-lg font-semibold">{detailTunnel.name}</h3>
|
||||
<Chip size="sm" color={detailTunnel.status === 1 ? "success" : "danger"} variant="flat">
|
||||
{detailTunnel.status === 1 ? "启用" : "禁用"}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||
<span className="text-[11px] text-default-500 mb-1.5 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
入口 → 出口 延迟
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.entryToExitLatency} loading={qualityLoading} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||
<span className="text-[11px] text-default-500 mb-1.5 flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
出口 → Bing 延迟
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.exitToBingLatency} loading={qualityLoading} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||
<span className="text-[11px] text-default-500 mb-1.5">入口 → 出口 丢包</span>
|
||||
<span className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}>
|
||||
{quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||
<span className="text-[11px] text-default-500 mb-1.5">出口 → Bing 丢包</span>
|
||||
<span className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}>
|
||||
{quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Auto-probe status */}
|
||||
<div className="flex items-center gap-2 text-xs text-default-500">
|
||||
<LiveDot />
|
||||
<span>自动探测中(每秒测试,30秒上报)</span>
|
||||
{quality?.timestamp && (
|
||||
<span className="text-default-400">
|
||||
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
{quality?.errorMessage && (
|
||||
<span className="text-danger ml-2">{quality.errorMessage}</span>
|
||||
)}
|
||||
</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];
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== LIST/GRID VIEW (unchanged) =====
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-1">
|
||||
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
|
||||
{lastQualityUpdate && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-default-500">
|
||||
<LiveDot />
|
||||
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tunnelsError ? (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="text-sm text-default-600">{tunnelsError}</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tunnels.map((tunnel) => {
|
||||
const quality = qualityMap[tunnel.id];
|
||||
const isEnabled = tunnel.status === 1;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={tunnel.id}
|
||||
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
|
||||
onClick={() => setDetailTunnelId(tunnel.id)}
|
||||
>
|
||||
<div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
||||
<div className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
||||
|
||||
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
|
||||
<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">
|
||||
<ArrowRightLeft className={`w-5 h-5 ${isEnabled ? "text-success" : "text-danger"}`} />
|
||||
</div>
|
||||
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<h3 className="font-semibold text-foreground text-sm truncate">{tunnel.name}</h3>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-default-500 mt-0.5">
|
||||
<span className="font-mono">{isEnabled ? "启用" : "禁用"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
入口→出口
|
||||
</div>
|
||||
<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>
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2 border-t border-divider/50">
|
||||
{quality?.errorMessage ? (
|
||||
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
|
||||
) : quality?.timestamp ? (
|
||||
<span className="text-[11px] text-default-500 flex items-center gap-1">
|
||||
<LiveDot />
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-default-400">等待探测...</span>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="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>
|
||||
<TableColumn>入口→出口</TableColumn>
|
||||
<TableColumn>出口→Bing</TableColumn>
|
||||
<TableColumn>更新时间</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="暂无隧道">
|
||||
{tunnels.map((tunnel) => {
|
||||
const quality = qualityMap[tunnel.id];
|
||||
const isEnabled = tunnel.status === 1;
|
||||
|
||||
return (
|
||||
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isEnabled ? (
|
||||
<Wifi className="w-3.5 h-3.5 text-success" />
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5 text-danger" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{quality?.timestamp ? (
|
||||
<span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap">
|
||||
<LiveDot />
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-default-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
import { LayoutGrid, List } from "lucide-react";
|
||||
|
||||
import {
|
||||
AnimatedPage,
|
||||
@@ -190,6 +191,10 @@ export default function UserPage() {
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
|
||||
"user-view-mode",
|
||||
"grid",
|
||||
);
|
||||
const [pagination, setPagination] = useState<PaginationType>({
|
||||
current: 1,
|
||||
size: 10,
|
||||
@@ -1040,9 +1045,23 @@ export default function UserPage() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 用户列表 */}
|
||||
@@ -1059,6 +1078,162 @@ export default function UserPage() {
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : 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>
|
||||
<TableColumn>规则数量</TableColumn>
|
||||
<TableColumn>到期时间</TableColumn>
|
||||
<TableColumn>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={users}>
|
||||
{(user) => {
|
||||
const userStatus = getUserStatus(user);
|
||||
const expStatus = user.expTime
|
||||
? getExpireStatus(user.expTime)
|
||||
: null;
|
||||
const usedFlow = calculateUserTotalUsedFlow(user);
|
||||
|
||||
return (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||
userStatus.color === "success"
|
||||
? "bg-success"
|
||||
: "bg-danger"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{user.name || user.user}
|
||||
</span>
|
||||
<span className="text-xs text-default-500">
|
||||
@{user.user}
|
||||
</span>
|
||||
</div>
|
||||
{user.disabledByQuota && (
|
||||
<Chip className="text-[10px] h-4 px-1 ml-1" color="danger" size="sm" variant="flat">
|
||||
超额
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-default-500">已用:</span>
|
||||
<span className="text-danger font-medium whitespace-nowrap">
|
||||
{formatFlow(usedFlow)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-default-500">限制:</span>
|
||||
<span className="text-default-700 font-medium whitespace-nowrap">
|
||||
{formatFlow(user.flow, "gb")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px]">
|
||||
{((user.dailyQuotaGB ?? 0) > 0 || (user.monthlyQuotaGB ?? 0) > 0) ? (
|
||||
<>
|
||||
<div className="flex gap-1 justify-between">
|
||||
<span className="text-default-500">日配额:</span>
|
||||
<span>{formatFlow(user.dailyUsedBytes ?? 0)} / {formatQuotaLimit(user.dailyQuotaGB)}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 justify-between">
|
||||
<span className="text-default-500">月配额:</span>
|
||||
<span>{formatFlow(user.monthlyUsedBytes ?? 0)} / {formatQuotaLimit(user.monthlyQuotaGB)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-default-400">无配额</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-medium">{user.num}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1 items-start whitespace-nowrap">
|
||||
{user.expTime ? (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
{new Date(user.expTime).toLocaleString()}
|
||||
</span>
|
||||
{expStatus && (
|
||||
<Chip
|
||||
color={expStatus.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
className="h-5 px-1 text-[10px]"
|
||||
>
|
||||
{expStatus.text}
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-default-400 text-sm">无限期</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
|
||||
onPress={() => handleManageTunnels(user)}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
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"
|
||||
title="重置流量"
|
||||
onPress={() => handleResetFlow(user)}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
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"
|
||||
onPress={() => handleEdit(user)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
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"
|
||||
onPress={() => handleDelete(user)}
|
||||
>
|
||||
删除
|
||||
</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">
|
||||
{users.map((user) => {
|
||||
@@ -1721,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 };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@theme inline {
|
||||
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-border: var(--border);
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user